feat: add causal frame set attention fusion
This commit is contained in:
@@ -43,6 +43,13 @@ result.
|
|||||||
- Ego4D batch and 8-GPU local-feature launchers, with strict frame audits.
|
- Ego4D batch and 8-GPU local-feature launchers, with strict frame audits.
|
||||||
- Timing-derived wearer floor-state and event diagnostics.
|
- Timing-derived wearer floor-state and event diagnostics.
|
||||||
|
|
||||||
|
### Implemented, awaiting remote runtime verification
|
||||||
|
|
||||||
|
- F1 Frame-set Attention loader/head/train/infer path. It uses fused G+A as
|
||||||
|
one query over the latest half-open causal person set, retains direct A in
|
||||||
|
the head, and dynamically pads people per batch. Do not report it as a
|
||||||
|
measured method until the L20 smoke and full controls pass.
|
||||||
|
|
||||||
### Current blocker
|
### Current blocker
|
||||||
|
|
||||||
`evaluate_floor_state_changes.py` counts a wearer transition anywhere in a
|
`evaluate_floor_state_changes.py` counts a wearer transition anywhere in a
|
||||||
|
|||||||
@@ -174,6 +174,27 @@ warmup_factor=0.3、dropout=0.2、LSTR-MCE、不加 transition objective。
|
|||||||
Loader 会检查 sample、时间、label_indices、维度、valid mask 和 encoder/pooling 配置。
|
Loader 会检查 sample、时间、label_indices、维度、valid mask 和 encoder/pooling 配置。
|
||||||
训练与评测必须用同一种 AP 协议作比较;不要混用 full-timeline 与 causal-valid 的表格。
|
训练与评测必须用同一种 AP 协议作比较;不要混用 full-timeline 与 causal-valid 的表格。
|
||||||
|
|
||||||
|
## 4. F1 Frame-set Attention
|
||||||
|
|
||||||
|
已有完整局部 sidecar 的 persons 字段保存了逐帧、逐人的原生 2048D 特征,因此 F1
|
||||||
|
不需要重新提取图片或音频。新增训练参数为:
|
||||||
|
|
||||||
|
--local-feature-root "$LOCAL_ROOT" \
|
||||||
|
--local-modalities av \
|
||||||
|
--local-fusion frame-set-attention \
|
||||||
|
--local-projection-dim 256 \
|
||||||
|
--person-attention-dim 256 \
|
||||||
|
--person-attention-heads 4
|
||||||
|
|
||||||
|
F1 用融合后的 G 和直接局部音频 A 构造一个 query,读取当前最新半开因果帧中的可变人物
|
||||||
|
集合。人物集合只在 batch 内动态 padding;NULL person 处理没有有效框的时刻。A 仍直接
|
||||||
|
进入最终 head。第一版不使用 bbox、track ID、跨帧 GRU 或双向 AV attention。
|
||||||
|
|
||||||
|
训练输出会打印 source frame 数、person observation 数、max_people 和 attention 配置。
|
||||||
|
推理只需继续提供 local feature root;融合方式和 attention 参数从 checkpoint 恢复。
|
||||||
|
正式比较需要重新训练同 split、seed 和优化设置的 pooled AV 对照,不能直接把不同开发
|
||||||
|
协议的旧 checkpoint 当作唯一基线。
|
||||||
|
|
||||||
## Ego4D 接口
|
## Ego4D 接口
|
||||||
|
|
||||||
完整命令见 [Ego4D 四组局部增强实验](ego4d_local_baselines.md):新增 manifest 批量提取、
|
完整命令见 [Ego4D 四组局部增强实验](ego4d_local_baselines.md):新增 manifest 批量提取、
|
||||||
|
|||||||
@@ -21,14 +21,17 @@
|
|||||||
融合实验从 F1 Frame-set Attention 开始。后续模块只有在前一级通过预先定义的 gate 后
|
融合实验从 F1 Frame-set Attention 开始。后续模块只有在前一级通过预先定义的 gate 后
|
||||||
才实施。
|
才实施。
|
||||||
|
|
||||||
这是一份实验设计,不代表 F1--F6 或 D1 已经实现或验证。
|
实现状态(2026-09-02):F1 的 loader、head、train/infer 路径和回归测试已经加入代码,
|
||||||
|
但尚未在远程 PyTorch/L20 环境完成 runtime smoke,不能写成已验证结果。F2--F6 和 D1
|
||||||
|
仍然只是计划。
|
||||||
|
|
||||||
## 2. 不可改变的实验条件
|
## 2. 不可改变的实验条件
|
||||||
|
|
||||||
所有阶段共同遵守:
|
所有阶段共同遵守:
|
||||||
|
|
||||||
- 目标与全局 state 均为 5 Hz;第 t 行只能使用截止到 (t+1)/5 秒的媒体。
|
- 目标与全局 state 均为 5 Hz;第 t 行只能使用截止到 (t+1)/5 秒的媒体。
|
||||||
- 视频人物特征若为 2 FPS,在 5 Hz 预测点选择时间不晚于 cutoff 的最新人物集合。
|
- 视频人物特征若为 2 FPS,在 5 Hz 预测点选择严格早于 cutoff 的最新人物集合,与已有
|
||||||
|
half-open local audio/visual 契约一致。
|
||||||
- 不允许用未来帧插值;同一 2 FPS 人物集合可以供多个 5 Hz query 读取,但不能伪装成
|
- 不允许用未来帧插值;同一 2 FPS 人物集合可以供多个 5 Hz query 读取,但不能伪装成
|
||||||
新观测去重复更新时序状态。
|
新观测去重复更新时序状态。
|
||||||
- EasyCom Session 4--12 为官方训练集,Session 1--3 为最终测试集。模型和阈值选择使用
|
- EasyCom Session 4--12 为官方训练集,Session 1--3 为最终测试集。模型和阈值选择使用
|
||||||
@@ -128,11 +131,11 @@ F1 暂不加入 bbox、track GRU、人物自注意力或 audio-to-person 反向
|
|||||||
|
|
||||||
对每个 5 Hz cutoff c_t:
|
对每个 5 Hz cutoff c_t:
|
||||||
|
|
||||||
1. 找到 frame_time 小于或等于 c_t 的最新 local frame;
|
1. 找到 frame_time 严格小于 c_t 的最新 local frame;
|
||||||
2. 读取该帧 frame_offsets 范围内全部逐人 feature;
|
2. 读取该帧 frame_offsets 范围内全部逐人 feature;
|
||||||
3. 如果尚无帧,使用仅含 NULL person 的集合;
|
3. 如果尚无帧,使用仅含 NULL person 的集合;
|
||||||
4. 不允许 nearest-neighbor 选到 c_t 之后的帧;
|
4. 不允许 nearest-neighbor 选到 c_t 之后的帧;
|
||||||
5. 记录 chosen_frame_time,测试所有 chosen_frame_time 小于或等于 cutoff。
|
5. 记录 chosen_frame_time,测试所有 chosen_frame_time 严格小于 cutoff。
|
||||||
|
|
||||||
### 5.5 F1 必跑对照
|
### 5.5 F1 必跑对照
|
||||||
|
|
||||||
|
|||||||
226
easycom_head.py
226
easycom_head.py
@@ -5,13 +5,16 @@ from __future__ import annotations
|
|||||||
import csv
|
import csv
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal, Sequence
|
from typing import TYPE_CHECKING, Any, Literal, Sequence
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
from torch import nn
|
from torch import nn
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from local_turn_features import FramePersonFeatures
|
||||||
|
|
||||||
|
|
||||||
OFFICIAL_TRAIN_SESSIONS = (
|
OFFICIAL_TRAIN_SESSIONS = (
|
||||||
"Session_7",
|
"Session_7",
|
||||||
@@ -58,6 +61,7 @@ LAYER_FUSION_MODES = (
|
|||||||
"scalar-mixture",
|
"scalar-mixture",
|
||||||
"concat-mlp",
|
"concat-mlp",
|
||||||
)
|
)
|
||||||
|
LOCAL_FUSION_MODES = ("pooled", "frame-set-attention")
|
||||||
DatasetName = Literal["easycom", "ego4d"]
|
DatasetName = Literal["easycom", "ego4d"]
|
||||||
|
|
||||||
|
|
||||||
@@ -77,6 +81,9 @@ class TurnPredictionHead(nn.Module):
|
|||||||
local_visual_dim: int = 0,
|
local_visual_dim: int = 0,
|
||||||
local_audio_dim: int = 0,
|
local_audio_dim: int = 0,
|
||||||
local_projection_dim: int = 256,
|
local_projection_dim: int = 256,
|
||||||
|
local_fusion: str = "pooled",
|
||||||
|
person_attention_dim: int = 256,
|
||||||
|
person_attention_heads: int = 4,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
if num_input_layers <= 0:
|
if num_input_layers <= 0:
|
||||||
@@ -116,6 +123,24 @@ class TurnPredictionHead(nn.Module):
|
|||||||
self.local_visual_dim = local_visual_dim
|
self.local_visual_dim = local_visual_dim
|
||||||
self.local_audio_dim = local_audio_dim
|
self.local_audio_dim = local_audio_dim
|
||||||
self.local_projection_dim = local_projection_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 local_fusion == "frame-set-attention" and (
|
||||||
|
local_visual_dim <= 0 or local_audio_dim <= 0
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Frame-set Attention 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
|
||||||
if self.learnable_layer_mixture:
|
if self.learnable_layer_mixture:
|
||||||
# Zero initialization exactly reproduces a uniform layer mean at
|
# Zero initialization exactly reproduces a uniform layer mean at
|
||||||
# step zero while allowing training to select useful query layers.
|
# step zero while allowing training to select useful query layers.
|
||||||
@@ -133,7 +158,12 @@ class TurnPredictionHead(nn.Module):
|
|||||||
self.layer_fusion_net = None
|
self.layer_fusion_net = None
|
||||||
self.local_projections = nn.ModuleDict()
|
self.local_projections = nn.ModuleDict()
|
||||||
fused_size = hidden_size
|
fused_size = hidden_size
|
||||||
for name, dimension in (("visual", local_visual_dim), ("audio", local_audio_dim)):
|
projected_modalities = (
|
||||||
|
(("audio", local_audio_dim),)
|
||||||
|
if local_fusion == "frame-set-attention"
|
||||||
|
else (("visual", local_visual_dim), ("audio", local_audio_dim))
|
||||||
|
)
|
||||||
|
for name, dimension in projected_modalities:
|
||||||
if dimension:
|
if dimension:
|
||||||
self.local_projections[name] = (
|
self.local_projections[name] = (
|
||||||
nn.Sequential(
|
nn.Sequential(
|
||||||
@@ -145,6 +175,36 @@ class TurnPredictionHead(nn.Module):
|
|||||||
else nn.Identity()
|
else nn.Identity()
|
||||||
)
|
)
|
||||||
fused_size += (local_projection_dim or dimension) + 1
|
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
|
||||||
|
if local_fusion == "frame-set-attention":
|
||||||
|
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)
|
||||||
|
fused_size += person_attention_dim + 1
|
||||||
self.net = nn.Sequential(
|
self.net = nn.Sequential(
|
||||||
nn.LayerNorm(fused_size),
|
nn.LayerNorm(fused_size),
|
||||||
nn.Linear(fused_size, hidden_size // 2),
|
nn.Linear(fused_size, hidden_size // 2),
|
||||||
@@ -190,17 +250,99 @@ class TurnPredictionHead(nn.Module):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def local_input_size(self) -> int:
|
def local_input_size(self) -> int:
|
||||||
|
if self.local_fusion == "frame-set-attention":
|
||||||
|
return self.local_audio_dim + 1
|
||||||
return sum(d + 1 for d in (self.local_visual_dim, self.local_audio_dim) if d)
|
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]:
|
||||||
|
if states.ndim != 2:
|
||||||
|
raise ValueError("Frame-set Attention expects batched [B,D] states")
|
||||||
|
if local.shape != (len(states), self.local_audio_dim + 1):
|
||||||
|
raise ValueError(
|
||||||
|
"Frame-set Attention 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(
|
||||||
|
"Frame-set Attention 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("Frame-set Attention 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
|
||||||
|
query = (
|
||||||
|
self.person_state_query(states)
|
||||||
|
+ self.person_audio_query(audio_values) * audio_valid
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
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, torch.cat(
|
||||||
|
(context[:, 0, :], person_valid),
|
||||||
|
dim=-1,
|
||||||
|
)
|
||||||
|
|
||||||
def _shared_features(
|
def _shared_features(
|
||||||
self,
|
self,
|
||||||
states: torch.Tensor,
|
states: torch.Tensor,
|
||||||
local: torch.Tensor | None = None,
|
local: torch.Tensor | None = None,
|
||||||
|
person_features: torch.Tensor | None = None,
|
||||||
|
person_mask: torch.Tensor | None = None,
|
||||||
) -> torch.Tensor:
|
) -> torch.Tensor:
|
||||||
"""Return the activity MLP representation shared by both readouts."""
|
"""Return the activity MLP representation shared by both readouts."""
|
||||||
|
|
||||||
states = self._fuse_layers(states)
|
states = self._fuse_layers(states)
|
||||||
if self.local_input_size:
|
if self.local_fusion == "frame-set-attention":
|
||||||
|
if local is None:
|
||||||
|
raise ValueError("Frame-set Attention requires local audio features")
|
||||||
|
audio, audio_valid, person_context = self._frame_set_context(
|
||||||
|
states,
|
||||||
|
local,
|
||||||
|
person_features,
|
||||||
|
person_mask,
|
||||||
|
)
|
||||||
|
states = torch.cat(
|
||||||
|
(states, audio, audio_valid, person_context),
|
||||||
|
dim=-1,
|
||||||
|
)
|
||||||
|
elif self.local_input_size:
|
||||||
if local is None or local.shape != (*states.shape[:-1], self.local_input_size):
|
if local is None or local.shape != (*states.shape[:-1], self.local_input_size):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Head requires aligned local features of width {self.local_input_size}"
|
f"Head requires aligned local features of width {self.local_input_size}"
|
||||||
@@ -220,14 +362,25 @@ class TurnPredictionHead(nn.Module):
|
|||||||
states = torch.cat(pieces, dim=-1)
|
states = torch.cat(pieces, dim=-1)
|
||||||
elif local is not None and local.shape[-1] != 0:
|
elif local is not None and local.shape[-1] != 0:
|
||||||
raise ValueError("Local features supplied to a baseline head")
|
raise ValueError("Local features supplied to a baseline head")
|
||||||
|
if self.local_fusion != "frame-set-attention" 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)
|
return self.net[:-1](states)
|
||||||
|
|
||||||
def forward(
|
def forward(
|
||||||
self,
|
self,
|
||||||
states: torch.Tensor,
|
states: torch.Tensor,
|
||||||
local: torch.Tensor | None = None,
|
local: torch.Tensor | None = None,
|
||||||
|
person_features: torch.Tensor | None = None,
|
||||||
|
person_mask: torch.Tensor | None = None,
|
||||||
) -> torch.Tensor:
|
) -> torch.Tensor:
|
||||||
features = self._shared_features(states, local)
|
features = self._shared_features(
|
||||||
|
states,
|
||||||
|
local,
|
||||||
|
person_features,
|
||||||
|
person_mask,
|
||||||
|
)
|
||||||
logits = self.net[-1](features)
|
logits = self.net[-1](features)
|
||||||
return logits.view(
|
return logits.view(
|
||||||
*features.shape[:-1], self.num_horizons, self.num_classes
|
*features.shape[:-1], self.num_horizons, self.num_classes
|
||||||
@@ -237,6 +390,8 @@ class TurnPredictionHead(nn.Module):
|
|||||||
self,
|
self,
|
||||||
states: torch.Tensor,
|
states: torch.Tensor,
|
||||||
local: torch.Tensor | None = None,
|
local: torch.Tensor | None = None,
|
||||||
|
person_features: torch.Tensor | None = None,
|
||||||
|
person_mask: torch.Tensor | None = None,
|
||||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||||
"""Return activity logits and binary transition logits."""
|
"""Return activity logits and binary transition logits."""
|
||||||
|
|
||||||
@@ -244,7 +399,12 @@ class TurnPredictionHead(nn.Module):
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"This head was created without transition_auxiliary=True"
|
"This head was created without transition_auxiliary=True"
|
||||||
)
|
)
|
||||||
features = self._shared_features(states, local)
|
features = self._shared_features(
|
||||||
|
states,
|
||||||
|
local,
|
||||||
|
person_features,
|
||||||
|
person_mask,
|
||||||
|
)
|
||||||
activity_logits = self.net[-1](features).view(
|
activity_logits = self.net[-1](features).view(
|
||||||
*features.shape[:-1],
|
*features.shape[:-1],
|
||||||
self.num_horizons,
|
self.num_horizons,
|
||||||
@@ -734,6 +894,7 @@ class SessionData:
|
|||||||
label_indices: torch.LongTensor
|
label_indices: torch.LongTensor
|
||||||
feature_metadata: dict[str, Any]
|
feature_metadata: dict[str, Any]
|
||||||
local_features: torch.Tensor | None = None
|
local_features: torch.Tensor | None = None
|
||||||
|
local_person_features: "FramePersonFeatures | None" = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hidden_size(self) -> int:
|
def hidden_size(self) -> int:
|
||||||
@@ -768,6 +929,7 @@ def load_session_data(
|
|||||||
local_feature_root: str | Path | None = None,
|
local_feature_root: str | Path | None = None,
|
||||||
local_modalities: str = "av",
|
local_modalities: str = "av",
|
||||||
local_contract: dict[str, Any] | None = None,
|
local_contract: dict[str, Any] | None = None,
|
||||||
|
local_fusion: str = "pooled",
|
||||||
) -> SessionData:
|
) -> SessionData:
|
||||||
"""Load and validate one saved Omni feature payload and target array."""
|
"""Load and validate one saved Omni feature payload and target array."""
|
||||||
|
|
||||||
@@ -864,22 +1026,55 @@ def load_session_data(
|
|||||||
"".join(str(int(value)) for value in row.tolist()): int(count)
|
"".join(str(int(value)) for value in row.tolist()): int(count)
|
||||||
for row, count in zip(unique_rows, row_counts)
|
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 = None
|
||||||
|
local_people = None
|
||||||
if local_feature_root is not None:
|
if local_feature_root is not None:
|
||||||
from local_turn_features import load_local_features
|
local_path = resolve_feature_path(
|
||||||
|
local_feature_root,
|
||||||
local, contract = load_local_features(
|
name,
|
||||||
resolve_feature_path(local_feature_root, name, dataset=dataset),
|
|
||||||
payload,
|
|
||||||
sample_id=name,
|
|
||||||
dataset=dataset,
|
dataset=dataset,
|
||||||
modalities=local_modalities,
|
|
||||||
expected_contract=local_contract,
|
|
||||||
)
|
)
|
||||||
|
if local_fusion == "frame-set-attention":
|
||||||
|
if local_modalities != "av":
|
||||||
|
raise ValueError(
|
||||||
|
"Frame-set Attention 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,
|
||||||
|
)
|
||||||
|
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
|
metadata["local_contract"] = contract
|
||||||
elif local_contract is not None:
|
elif local_contract is not None:
|
||||||
raise ValueError("--local-feature-root is required for this checkpoint")
|
raise ValueError("--local-feature-root is required for this checkpoint")
|
||||||
return SessionData(name, states, targets, label_indices, metadata, local)
|
elif local_fusion != "pooled":
|
||||||
|
raise ValueError("Frame-set Attention requires --local-feature-root")
|
||||||
|
return SessionData(
|
||||||
|
name,
|
||||||
|
states,
|
||||||
|
targets,
|
||||||
|
label_indices,
|
||||||
|
metadata,
|
||||||
|
local,
|
||||||
|
local_people,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def average_precision_metrics(
|
def average_precision_metrics(
|
||||||
@@ -924,6 +1119,9 @@ def checkpoint_head_config(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
config.setdefault("local_visual_dim", 0)
|
config.setdefault("local_visual_dim", 0)
|
||||||
config.setdefault("local_audio_dim", 0)
|
config.setdefault("local_audio_dim", 0)
|
||||||
config.setdefault("local_projection_dim", 256)
|
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(
|
config.setdefault(
|
||||||
"layer_fusion",
|
"layer_fusion",
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from easycom_head import (
|
|||||||
output_space_for_loss,
|
output_space_for_loss,
|
||||||
transition_targets_from_activity,
|
transition_targets_from_activity,
|
||||||
)
|
)
|
||||||
|
from local_turn_features import FramePersonFeatures
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
@@ -118,6 +119,7 @@ def _predict_states(
|
|||||||
show_progress: bool,
|
show_progress: bool,
|
||||||
loss_name: str,
|
loss_name: str,
|
||||||
local_features: torch.Tensor | None = None,
|
local_features: torch.Tensor | None = None,
|
||||||
|
local_person_features: FramePersonFeatures | None = None,
|
||||||
) -> tuple[np.ndarray, np.ndarray | None]:
|
) -> tuple[np.ndarray, np.ndarray | None]:
|
||||||
chunks: list[torch.Tensor] = []
|
chunks: list[torch.Tensor] = []
|
||||||
transition_chunks: list[torch.Tensor] = []
|
transition_chunks: list[torch.Tensor] = []
|
||||||
@@ -139,11 +141,35 @@ def _predict_states(
|
|||||||
local_features[start : start + batch_size].to(device=device, dtype=torch.float32)
|
local_features[start : start + batch_size].to(device=device, dtype=torch.float32)
|
||||||
if local_features is not None else None
|
if local_features is not None else None
|
||||||
)
|
)
|
||||||
|
person_batch = None
|
||||||
|
person_mask = None
|
||||||
|
if local_person_features is not None:
|
||||||
|
rows = torch.arange(
|
||||||
|
start,
|
||||||
|
min(start + batch_size, len(states)),
|
||||||
|
)
|
||||||
|
person_batch, person_mask = local_person_features.padded(rows)
|
||||||
|
person_batch = person_batch.to(
|
||||||
|
device=device,
|
||||||
|
dtype=torch.float32,
|
||||||
|
non_blocking=True,
|
||||||
|
)
|
||||||
|
person_mask = person_mask.to(device=device, non_blocking=True)
|
||||||
if head.transition_auxiliary:
|
if head.transition_auxiliary:
|
||||||
activity_logits, transition_logits = head.forward_with_transition(batch, side)
|
activity_logits, transition_logits = head.forward_with_transition(
|
||||||
|
batch,
|
||||||
|
side,
|
||||||
|
person_batch,
|
||||||
|
person_mask,
|
||||||
|
)
|
||||||
transition_chunks.append(transition_logits.sigmoid().cpu())
|
transition_chunks.append(transition_logits.sigmoid().cpu())
|
||||||
else:
|
else:
|
||||||
activity_logits = head(batch, side)
|
activity_logits = head(
|
||||||
|
batch,
|
||||||
|
side,
|
||||||
|
person_batch,
|
||||||
|
person_mask,
|
||||||
|
)
|
||||||
chunks.append(
|
chunks.append(
|
||||||
marginal_probabilities_from_logits(activity_logits, loss_name).cpu()
|
marginal_probabilities_from_logits(activity_logits, loss_name).cpu()
|
||||||
)
|
)
|
||||||
@@ -403,6 +429,7 @@ def main() -> None:
|
|||||||
local_feature_root=args.local_feature_root,
|
local_feature_root=args.local_feature_root,
|
||||||
local_modalities=local_contract["modalities"] if local_contract else "av",
|
local_modalities=local_contract["modalities"] if local_contract else "av",
|
||||||
local_contract=local_contract,
|
local_contract=local_contract,
|
||||||
|
local_fusion=head.local_fusion,
|
||||||
)
|
)
|
||||||
if data.hidden_size != int(head_config["hidden_size"]):
|
if data.hidden_size != int(head_config["hidden_size"]):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -423,6 +450,7 @@ def main() -> None:
|
|||||||
show_progress=not args.no_progress,
|
show_progress=not args.no_progress,
|
||||||
loss_name=loss_name,
|
loss_name=loss_name,
|
||||||
local_features=data.local_features,
|
local_features=data.local_features,
|
||||||
|
local_person_features=data.local_person_features,
|
||||||
)
|
)
|
||||||
predictions, valid = _align_to_egospeak_timeline(
|
predictions, valid = _align_to_egospeak_timeline(
|
||||||
probabilities,
|
probabilities,
|
||||||
@@ -484,6 +512,7 @@ def main() -> None:
|
|||||||
"checkpoint_train_sessions": checkpoint_payload.get("train_sessions"),
|
"checkpoint_train_sessions": checkpoint_payload.get("train_sessions"),
|
||||||
"checkpoint_dev_sessions": checkpoint_payload.get("dev_sessions"),
|
"checkpoint_dev_sessions": checkpoint_payload.get("dev_sessions"),
|
||||||
"layer_fusion": head.layer_fusion,
|
"layer_fusion": head.layer_fusion,
|
||||||
|
"local_fusion": head.local_fusion,
|
||||||
"local_contract": local_contract,
|
"local_contract": local_contract,
|
||||||
"transition_auxiliary": head.transition_auxiliary,
|
"transition_auxiliary": head.transition_auxiliary,
|
||||||
"transition_objective": checkpoint_payload.get(
|
"transition_objective": checkpoint_payload.get(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Validated, causal local-feature sidecars for the pooled B1 baseline.
|
"""Validated, causal pooled and frame-set local-feature sidecars.
|
||||||
|
|
||||||
No model/decoder imports: these utilities are also used by CPU head training.
|
No model/decoder imports: these utilities are also used by CPU head training.
|
||||||
Sidecars store encoder-native vectors, never a randomly initialized projection.
|
Sidecars store encoder-native vectors, never a randomly initialized projection.
|
||||||
@@ -6,6 +6,7 @@ Sidecars store encoder-native vectors, never a randomly initialized projection.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
import math
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -16,6 +17,150 @@ LOCAL_FORMAT = "qwen-local-pooled-v1"
|
|||||||
MODALITIES = {"visual": ("visual",), "audio": ("audio",), "av": ("visual", "audio")}
|
MODALITIES = {"visual": ("visual",), "audio": ("audio",), "av": ("visual", "audio")}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FramePersonFeatures:
|
||||||
|
"""Ragged person sets indexed by aligned 5 Hz rows without frame duplication."""
|
||||||
|
|
||||||
|
features: torch.Tensor
|
||||||
|
frame_offsets: torch.LongTensor
|
||||||
|
row_to_frame: torch.LongTensor
|
||||||
|
chosen_frame_times: torch.Tensor
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if (
|
||||||
|
not isinstance(self.features, torch.Tensor)
|
||||||
|
or self.features.ndim != 2
|
||||||
|
or not self.features.is_floating_point()
|
||||||
|
or not torch.isfinite(self.features).all()
|
||||||
|
or self.features.shape[1] <= 0
|
||||||
|
):
|
||||||
|
raise ValueError("Person features must be finite floating [N,D]")
|
||||||
|
if (
|
||||||
|
not isinstance(self.frame_offsets, torch.Tensor)
|
||||||
|
or self.frame_offsets.dtype != torch.long
|
||||||
|
or self.frame_offsets.ndim != 1
|
||||||
|
or not len(self.frame_offsets)
|
||||||
|
or int(self.frame_offsets[0]) != 0
|
||||||
|
or int(self.frame_offsets[-1]) != len(self.features)
|
||||||
|
or bool((self.frame_offsets[1:] < self.frame_offsets[:-1]).any())
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid person frame_offsets")
|
||||||
|
if (
|
||||||
|
not isinstance(self.row_to_frame, torch.Tensor)
|
||||||
|
or self.row_to_frame.dtype != torch.long
|
||||||
|
or self.row_to_frame.ndim != 1
|
||||||
|
):
|
||||||
|
raise ValueError("row_to_frame must be int64 [T]")
|
||||||
|
num_frames = len(self.frame_offsets) - 1
|
||||||
|
if bool(((self.row_to_frame < -1) | (self.row_to_frame >= num_frames)).any()):
|
||||||
|
raise ValueError("row_to_frame contains an invalid frame index")
|
||||||
|
if (
|
||||||
|
not isinstance(self.chosen_frame_times, torch.Tensor)
|
||||||
|
or self.chosen_frame_times.dtype != torch.float64
|
||||||
|
or self.chosen_frame_times.shape != self.row_to_frame.shape
|
||||||
|
):
|
||||||
|
raise ValueError("chosen_frame_times must be float64 [T]")
|
||||||
|
missing = self.row_to_frame < 0
|
||||||
|
if not torch.equal(torch.isnan(self.chosen_frame_times), missing):
|
||||||
|
raise ValueError("Only rows without an available frame may have NaN frame time")
|
||||||
|
if not torch.isfinite(self.chosen_frame_times[~missing]).all():
|
||||||
|
raise ValueError("Chosen person frame times must be finite")
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.row_to_frame)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dimension(self) -> int:
|
||||||
|
return int(self.features.shape[1])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_people(self) -> int:
|
||||||
|
counts = self.frame_offsets[1:] - self.frame_offsets[:-1]
|
||||||
|
return int(counts.max()) if len(counts) else 0
|
||||||
|
|
||||||
|
def select_rows(self, indices: torch.Tensor) -> "FramePersonFeatures":
|
||||||
|
indices = torch.as_tensor(indices)
|
||||||
|
if indices.dtype != torch.long or indices.ndim != 1:
|
||||||
|
raise ValueError("Person row selection must be int64 [N]")
|
||||||
|
if len(indices) and (int(indices.min()) < 0 or int(indices.max()) >= len(self)):
|
||||||
|
raise IndexError("Person row selection is out of range")
|
||||||
|
return FramePersonFeatures(
|
||||||
|
self.features,
|
||||||
|
self.frame_offsets,
|
||||||
|
self.row_to_frame[indices],
|
||||||
|
self.chosen_frame_times[indices],
|
||||||
|
)
|
||||||
|
|
||||||
|
def padded(
|
||||||
|
self,
|
||||||
|
row_indices: torch.Tensor | None = None,
|
||||||
|
) -> tuple[torch.Tensor, torch.BoolTensor]:
|
||||||
|
"""Materialize only one batch as [B,P,D] plus a True-for-real mask."""
|
||||||
|
|
||||||
|
if row_indices is None:
|
||||||
|
frames = self.row_to_frame
|
||||||
|
else:
|
||||||
|
row_indices = torch.as_tensor(row_indices)
|
||||||
|
if row_indices.dtype != torch.long or row_indices.ndim != 1:
|
||||||
|
raise ValueError("Batch row indices must be int64 [B]")
|
||||||
|
if len(row_indices) and (
|
||||||
|
int(row_indices.min()) < 0 or int(row_indices.max()) >= len(self)
|
||||||
|
):
|
||||||
|
raise IndexError("Batch person row index is out of range")
|
||||||
|
frames = self.row_to_frame[row_indices]
|
||||||
|
valid_frames = frames >= 0
|
||||||
|
safe_frames = frames.clamp_min(0)
|
||||||
|
starts = torch.zeros_like(frames)
|
||||||
|
counts = torch.zeros_like(frames)
|
||||||
|
if bool(valid_frames.any()):
|
||||||
|
starts[valid_frames] = self.frame_offsets[safe_frames[valid_frames]]
|
||||||
|
counts[valid_frames] = (
|
||||||
|
self.frame_offsets[safe_frames[valid_frames] + 1]
|
||||||
|
- starts[valid_frames]
|
||||||
|
)
|
||||||
|
width = int(counts.max()) if len(counts) else 0
|
||||||
|
mask = torch.arange(width)[None, :] < counts[:, None]
|
||||||
|
if width == 0:
|
||||||
|
return self.features.new_zeros((len(frames), 0, self.dimension)), mask
|
||||||
|
person_indices = starts[:, None] + torch.arange(width)[None, :]
|
||||||
|
safe_indices = person_indices.masked_fill(~mask, 0)
|
||||||
|
values = self.features[safe_indices]
|
||||||
|
return values.masked_fill(~mask[..., None], 0), mask
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def concatenate(
|
||||||
|
cls,
|
||||||
|
parts: list["FramePersonFeatures"],
|
||||||
|
) -> "FramePersonFeatures":
|
||||||
|
if not parts:
|
||||||
|
raise ValueError("Cannot concatenate no person feature sets")
|
||||||
|
dimension = parts[0].dimension
|
||||||
|
if any(part.dimension != dimension for part in parts):
|
||||||
|
raise ValueError("Person feature dimensions differ across sessions")
|
||||||
|
feature_parts = []
|
||||||
|
offset_parts = [torch.zeros(1, dtype=torch.long)]
|
||||||
|
row_parts = []
|
||||||
|
time_parts = []
|
||||||
|
feature_base = 0
|
||||||
|
frame_base = 0
|
||||||
|
for part in parts:
|
||||||
|
feature_parts.append(part.features)
|
||||||
|
offset_parts.append(part.frame_offsets[1:] + feature_base)
|
||||||
|
rows = part.row_to_frame.clone()
|
||||||
|
valid = rows >= 0
|
||||||
|
rows[valid] += frame_base
|
||||||
|
row_parts.append(rows)
|
||||||
|
time_parts.append(part.chosen_frame_times)
|
||||||
|
feature_base += len(part.features)
|
||||||
|
frame_base += len(part.frame_offsets) - 1
|
||||||
|
return cls(
|
||||||
|
torch.cat(feature_parts, dim=0),
|
||||||
|
torch.cat(offset_parts, dim=0),
|
||||||
|
torch.cat(row_parts, dim=0),
|
||||||
|
torch.cat(time_parts, dim=0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def reference_alignment(payload: dict[str, Any]) -> tuple[torch.Tensor, torch.Tensor]:
|
def reference_alignment(payload: dict[str, Any]) -> tuple[torch.Tensor, torch.Tensor]:
|
||||||
"""Require the same full, zero-origin 5 Hz timeline used by head training."""
|
"""Require the same full, zero-origin 5 Hz timeline used by head training."""
|
||||||
states = payload.get("states")
|
states = payload.get("states")
|
||||||
@@ -34,16 +179,15 @@ def reference_alignment(payload: dict[str, Any]) -> tuple[torch.Tensor, torch.Te
|
|||||||
return times, indices
|
return times, indices
|
||||||
|
|
||||||
|
|
||||||
def load_local_features(
|
def _load_validated_payload(
|
||||||
path: Path,
|
path: Path,
|
||||||
reference: dict[str, Any],
|
reference: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
sample_id: str,
|
sample_id: str,
|
||||||
dataset: str,
|
dataset: str,
|
||||||
modalities: str,
|
modalities: str,
|
||||||
expected_contract: dict[str, Any] | None = None,
|
expected_contract: dict[str, Any] | None,
|
||||||
) -> tuple[torch.Tensor, dict[str, Any]]:
|
) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||||
"""Pack each requested modality as [raw vector, validity bit]. Fail closed."""
|
|
||||||
if modalities not in MODALITIES:
|
if modalities not in MODALITIES:
|
||||||
raise ValueError(f"Unknown local modalities: {modalities}")
|
raise ValueError(f"Unknown local modalities: {modalities}")
|
||||||
times, indices = reference_alignment(reference)
|
times, indices = reference_alignment(reference)
|
||||||
@@ -66,6 +210,27 @@ def load_local_features(
|
|||||||
contract = {"format": LOCAL_FORMAT, "modalities": modalities, "config": config}
|
contract = {"format": LOCAL_FORMAT, "modalities": modalities, "config": config}
|
||||||
if expected_contract is not None and contract != expected_contract:
|
if expected_contract is not None and contract != expected_contract:
|
||||||
raise ValueError(f"Local encoder/pooling contract differs across sessions/checkpoint: {path}")
|
raise ValueError(f"Local encoder/pooling contract differs across sessions/checkpoint: {path}")
|
||||||
|
return times, indices, payload, config, contract
|
||||||
|
|
||||||
|
|
||||||
|
def load_local_features(
|
||||||
|
path: Path,
|
||||||
|
reference: dict[str, Any],
|
||||||
|
*,
|
||||||
|
sample_id: str,
|
||||||
|
dataset: str,
|
||||||
|
modalities: str,
|
||||||
|
expected_contract: dict[str, Any] | None = None,
|
||||||
|
) -> tuple[torch.Tensor, dict[str, Any]]:
|
||||||
|
"""Pack each requested modality as [raw vector, validity bit]. Fail closed."""
|
||||||
|
times, _, payload, config, contract = _load_validated_payload(
|
||||||
|
path,
|
||||||
|
reference,
|
||||||
|
sample_id=sample_id,
|
||||||
|
dataset=dataset,
|
||||||
|
modalities=modalities,
|
||||||
|
expected_contract=expected_contract,
|
||||||
|
)
|
||||||
chunks = []
|
chunks = []
|
||||||
for name in MODALITIES[modalities]:
|
for name in MODALITIES[modalities]:
|
||||||
values = payload.get(name)
|
values = payload.get(name)
|
||||||
@@ -85,6 +250,109 @@ def load_local_features(
|
|||||||
return torch.cat(chunks, dim=-1), contract
|
return torch.cat(chunks, dim=-1), contract
|
||||||
|
|
||||||
|
|
||||||
|
def load_frame_set_features(
|
||||||
|
path: Path,
|
||||||
|
reference: dict[str, Any],
|
||||||
|
*,
|
||||||
|
sample_id: str,
|
||||||
|
dataset: str,
|
||||||
|
expected_contract: dict[str, Any] | None = None,
|
||||||
|
) -> tuple[torch.Tensor, FramePersonFeatures, dict[str, Any]]:
|
||||||
|
"""Load direct audio and the latest causal per-frame person set per 5 Hz row."""
|
||||||
|
|
||||||
|
times, _, payload, config, contract = _load_validated_payload(
|
||||||
|
path,
|
||||||
|
reference,
|
||||||
|
sample_id=sample_id,
|
||||||
|
dataset=dataset,
|
||||||
|
modalities="av",
|
||||||
|
expected_contract=expected_contract,
|
||||||
|
)
|
||||||
|
audio = payload.get("audio")
|
||||||
|
audio_valid = payload.get("audio_valid")
|
||||||
|
audio_dim = config.get("audio_dim")
|
||||||
|
if not isinstance(audio_dim, int) or audio_dim <= 0:
|
||||||
|
raise ValueError(f"Missing audio_dim: {path}")
|
||||||
|
if not isinstance(audio, torch.Tensor) or audio.shape != (len(times), audio_dim):
|
||||||
|
raise ValueError(f"Invalid audio shape: {path}")
|
||||||
|
if not audio.is_floating_point() or not torch.isfinite(audio).all():
|
||||||
|
raise ValueError(f"Non-finite/non-floating audio features: {path}")
|
||||||
|
if (
|
||||||
|
not isinstance(audio_valid, torch.Tensor)
|
||||||
|
or audio_valid.dtype != torch.bool
|
||||||
|
or audio_valid.shape != (len(times),)
|
||||||
|
):
|
||||||
|
raise ValueError(f"Invalid audio_valid mask: {path}")
|
||||||
|
if torch.count_nonzero(audio[~audio_valid]):
|
||||||
|
raise ValueError(f"Invalid audio rows must be stored as zeros: {path}")
|
||||||
|
|
||||||
|
visual_dim = config.get("visual_dim")
|
||||||
|
persons = payload.get("persons")
|
||||||
|
if not isinstance(visual_dim, int) or visual_dim <= 0:
|
||||||
|
raise ValueError(f"Missing visual_dim: {path}")
|
||||||
|
if not isinstance(persons, dict):
|
||||||
|
raise ValueError(f"Missing ragged person features: {path}")
|
||||||
|
features = persons.get("features")
|
||||||
|
frame_times = persons.get("frame_times")
|
||||||
|
frame_offsets = persons.get("frame_offsets")
|
||||||
|
records = persons.get("records")
|
||||||
|
if (
|
||||||
|
not isinstance(features, torch.Tensor)
|
||||||
|
or features.ndim != 2
|
||||||
|
or features.shape[1] != visual_dim
|
||||||
|
or not features.is_floating_point()
|
||||||
|
or not torch.isfinite(features).all()
|
||||||
|
):
|
||||||
|
raise ValueError(f"Invalid person feature shape/content: {path}")
|
||||||
|
if (
|
||||||
|
not isinstance(frame_times, torch.Tensor)
|
||||||
|
or frame_times.ndim != 1
|
||||||
|
or not frame_times.is_floating_point()
|
||||||
|
):
|
||||||
|
raise ValueError(f"Invalid person frame_times: {path}")
|
||||||
|
frame_times = frame_times.to(dtype=torch.float64)
|
||||||
|
if (
|
||||||
|
not torch.isfinite(frame_times).all()
|
||||||
|
or bool((frame_times < 0).any())
|
||||||
|
or bool((frame_times[1:] <= frame_times[:-1]).any())
|
||||||
|
):
|
||||||
|
raise ValueError(f"Person frame_times must be finite, nonnegative and increasing: {path}")
|
||||||
|
if (
|
||||||
|
not isinstance(frame_offsets, torch.Tensor)
|
||||||
|
or frame_offsets.dtype != torch.long
|
||||||
|
or frame_offsets.shape != (len(frame_times) + 1,)
|
||||||
|
or not len(frame_offsets)
|
||||||
|
or int(frame_offsets[0]) != 0
|
||||||
|
or int(frame_offsets[-1]) != len(features)
|
||||||
|
or bool((frame_offsets[1:] < frame_offsets[:-1]).any())
|
||||||
|
):
|
||||||
|
raise ValueError(f"Invalid person frame_offsets: {path}")
|
||||||
|
if not isinstance(records, list) or len(records) != len(features):
|
||||||
|
raise ValueError(f"Person records/features length mismatch: {path}")
|
||||||
|
if len(frame_times) and float(frame_times[-1]) > float(times[-1]) + 1e-8:
|
||||||
|
raise ValueError(f"Person frame timeline extends beyond the reference: {path}")
|
||||||
|
|
||||||
|
# Match the sidecar's half-open policy: a frame stamped exactly at the
|
||||||
|
# state cutoff is not yet part of that state's observed prefix.
|
||||||
|
row_to_frame = torch.searchsorted(frame_times, times, right=False) - 1
|
||||||
|
chosen = torch.full(times.shape, float("nan"), dtype=torch.float64)
|
||||||
|
has_frame = row_to_frame >= 0
|
||||||
|
chosen[has_frame] = frame_times[row_to_frame[has_frame]]
|
||||||
|
if bool((chosen[has_frame] >= times[has_frame]).any()):
|
||||||
|
raise ValueError(f"Person frame mapping selected future visual evidence: {path}")
|
||||||
|
frame_sets = FramePersonFeatures(
|
||||||
|
features,
|
||||||
|
frame_offsets,
|
||||||
|
row_to_frame.to(dtype=torch.long),
|
||||||
|
chosen,
|
||||||
|
)
|
||||||
|
direct_audio = torch.cat(
|
||||||
|
(audio.float(), audio_valid[:, None].float()),
|
||||||
|
dim=-1,
|
||||||
|
)
|
||||||
|
return direct_audio, frame_sets, contract
|
||||||
|
|
||||||
|
|
||||||
def local_head_dimensions(contract: dict[str, Any] | None) -> dict[str, int]:
|
def local_head_dimensions(contract: dict[str, Any] | None) -> dict[str, int]:
|
||||||
if contract is None:
|
if contract is None:
|
||||||
return {"local_visual_dim": 0, "local_audio_dim": 0}
|
return {"local_visual_dim": 0, "local_audio_dim": 0}
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ from extract_turn_local_features import (
|
|||||||
main as extract_main,
|
main as extract_main,
|
||||||
)
|
)
|
||||||
from local_turn_features import (
|
from local_turn_features import (
|
||||||
LOCAL_FORMAT, causal_frame_ticks, crop_box, easycom_boxes, load_local_features,
|
LOCAL_FORMAT, FramePersonFeatures, causal_frame_ticks, crop_box, easycom_boxes,
|
||||||
local_head_dimensions, pooled_recent_visual, reference_alignment,
|
load_frame_set_features, load_local_features, local_head_dimensions,
|
||||||
|
pooled_recent_visual, reference_alignment,
|
||||||
)
|
)
|
||||||
from train_turn_head import _load_examples
|
from train_turn_head import _load_examples
|
||||||
|
|
||||||
@@ -47,6 +48,17 @@ def fixture(n=20, name="Session_4", dataset="easycom"):
|
|||||||
"causal_policy": "half-open-trailing-window", "uses_speech_labels": False}}
|
"causal_policy": "half-open-trailing-window", "uses_speech_labels": False}}
|
||||||
side["visual_valid"][0] = False
|
side["visual_valid"][0] = False
|
||||||
side["visual"][0] = 0
|
side["visual"][0] = 0
|
||||||
|
frame_times = torch.arange(max(1, (n + 2) // 3), dtype=torch.float64) * .5
|
||||||
|
counts = [2 if index % 3 == 0 else (0 if index % 3 == 1 else 1)
|
||||||
|
for index in range(len(frame_times))]
|
||||||
|
offsets = torch.tensor([0, *np.cumsum(counts).tolist()], dtype=torch.long)
|
||||||
|
person_features = torch.randn(int(offsets[-1]), 6)
|
||||||
|
side["persons"] = {
|
||||||
|
"features": person_features,
|
||||||
|
"frame_times": frame_times,
|
||||||
|
"frame_offsets": offsets,
|
||||||
|
"records": [{"id": str(index)} for index in range(len(person_features))],
|
||||||
|
}
|
||||||
labels = np.tile(np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1], [0, 1, 1]], dtype=np.float32), (n // 4 + 1, 1))[:n]
|
labels = np.tile(np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1], [0, 1, 1]], dtype=np.float32), (n // 4 + 1, 1))[:n]
|
||||||
return reference, side, labels
|
return reference, side, labels
|
||||||
|
|
||||||
@@ -280,6 +292,148 @@ class LocalTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, "times"):
|
with self.assertRaisesRegex(ValueError, "times"):
|
||||||
reference_alignment({**reference, "times": reference["times"] + .2})
|
reference_alignment({**reference, "times": reference["times"] + .2})
|
||||||
|
|
||||||
|
def test_frame_set_loader_is_causal_ragged_and_fail_closed(self):
|
||||||
|
reference, side, _ = fixture()
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "Session_4.pt"
|
||||||
|
torch.save(side, path)
|
||||||
|
audio, people, contract = load_frame_set_features(
|
||||||
|
path,
|
||||||
|
reference,
|
||||||
|
sample_id="Session_4",
|
||||||
|
dataset="easycom",
|
||||||
|
)
|
||||||
|
self.assertEqual(audio.shape, (20, 6))
|
||||||
|
self.assertEqual(contract["modalities"], "av")
|
||||||
|
self.assertEqual(people.dimension, 6)
|
||||||
|
self.assertTrue(
|
||||||
|
torch.all(
|
||||||
|
people.chosen_frame_times[
|
||||||
|
torch.isfinite(people.chosen_frame_times)
|
||||||
|
]
|
||||||
|
< reference["times"][
|
||||||
|
torch.isfinite(people.chosen_frame_times)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# At 0.2/0.4s, both rows reuse the 0.0s frame with two people.
|
||||||
|
values, mask = people.padded(torch.tensor([0, 1, 2]))
|
||||||
|
self.assertEqual(values.shape, (3, 2, 6))
|
||||||
|
self.assertEqual(mask.tolist(), [[True, True], [True, True], [False, False]])
|
||||||
|
self.assertTrue(torch.equal(values[0], values[1]))
|
||||||
|
self.assertEqual(torch.count_nonzero(values[2]), 0)
|
||||||
|
# The frame stamped exactly 1.0s is excluded at the 1.0s cutoff.
|
||||||
|
self.assertEqual(people.chosen_frame_times[4].item(), .5)
|
||||||
|
|
||||||
|
broken_cases = []
|
||||||
|
broken = copy.deepcopy(side)
|
||||||
|
broken["persons"]["frame_offsets"][-1] -= 1
|
||||||
|
broken_cases.append(broken)
|
||||||
|
broken = copy.deepcopy(side)
|
||||||
|
broken["persons"]["frame_times"][1] = broken["persons"]["frame_times"][0]
|
||||||
|
broken_cases.append(broken)
|
||||||
|
broken = copy.deepcopy(side)
|
||||||
|
broken["persons"]["records"] = []
|
||||||
|
broken_cases.append(broken)
|
||||||
|
broken = copy.deepcopy(side)
|
||||||
|
broken["persons"]["features"][0, 0] = float("nan")
|
||||||
|
broken_cases.append(broken)
|
||||||
|
for index, broken in enumerate(broken_cases):
|
||||||
|
torch.save(broken, path)
|
||||||
|
with self.subTest(index=index), self.assertRaises(ValueError):
|
||||||
|
load_frame_set_features(
|
||||||
|
path,
|
||||||
|
reference,
|
||||||
|
sample_id="Session_4",
|
||||||
|
dataset="easycom",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_frame_person_concatenation_selection_and_empty_padding(self):
|
||||||
|
features = torch.tensor([[1., 2.], [3., 4.]])
|
||||||
|
a = FramePersonFeatures(
|
||||||
|
features,
|
||||||
|
torch.tensor([0, 2], dtype=torch.long),
|
||||||
|
torch.tensor([0, -1], dtype=torch.long),
|
||||||
|
torch.tensor([0., float("nan")], dtype=torch.float64),
|
||||||
|
)
|
||||||
|
b = FramePersonFeatures(
|
||||||
|
torch.empty(0, 2),
|
||||||
|
torch.tensor([0, 0], dtype=torch.long),
|
||||||
|
torch.tensor([0], dtype=torch.long),
|
||||||
|
torch.tensor([.5], dtype=torch.float64),
|
||||||
|
)
|
||||||
|
combined = FramePersonFeatures.concatenate([a, b])
|
||||||
|
self.assertEqual(len(combined), 3)
|
||||||
|
self.assertEqual(combined.row_to_frame.tolist(), [0, -1, 1])
|
||||||
|
selected = combined.select_rows(torch.tensor([2, 0]))
|
||||||
|
values, mask = selected.padded()
|
||||||
|
self.assertEqual(mask.tolist(), [[False, False], [True, True]])
|
||||||
|
self.assertEqual(torch.count_nonzero(values[0]), 0)
|
||||||
|
self.assertTrue(torch.equal(values[1], features))
|
||||||
|
|
||||||
|
def test_frame_set_head_is_permutation_invariant_and_masks_padding(self):
|
||||||
|
torch.manual_seed(7)
|
||||||
|
head = TurnPredictionHead(
|
||||||
|
hidden_size=8,
|
||||||
|
num_input_layers=4,
|
||||||
|
layer_fusion="concat-mlp",
|
||||||
|
local_visual_dim=6,
|
||||||
|
local_audio_dim=5,
|
||||||
|
local_projection_dim=3,
|
||||||
|
local_fusion="frame-set-attention",
|
||||||
|
person_attention_dim=4,
|
||||||
|
person_attention_heads=2,
|
||||||
|
transition_auxiliary=True,
|
||||||
|
dropout=0,
|
||||||
|
).eval()
|
||||||
|
states = torch.randn(3, 4, 8)
|
||||||
|
local = torch.randn(3, 6)
|
||||||
|
local[:, -1] = 1
|
||||||
|
people = torch.randn(3, 3, 6)
|
||||||
|
mask = torch.tensor(
|
||||||
|
[[True, True, False], [True, False, False], [False, False, False]]
|
||||||
|
)
|
||||||
|
people[~mask] = 0
|
||||||
|
logits, transitions = head.forward_with_transition(
|
||||||
|
states,
|
||||||
|
local,
|
||||||
|
people,
|
||||||
|
mask,
|
||||||
|
)
|
||||||
|
self.assertEqual(logits.shape, (3, 10, 3))
|
||||||
|
self.assertEqual(transitions.shape, (3, 10))
|
||||||
|
(logits.square().mean() + transitions.square().mean()).backward()
|
||||||
|
self.assertIsNotNone(head.person_attention.in_proj_weight.grad)
|
||||||
|
|
||||||
|
permutation = torch.tensor([1, 0, 2])
|
||||||
|
permuted = head(states, local, people[:, permutation], mask[:, permutation])
|
||||||
|
self.assertTrue(torch.allclose(logits, permuted, atol=1e-6, rtol=1e-6))
|
||||||
|
changed = people.clone()
|
||||||
|
changed[~mask] = 1000
|
||||||
|
self.assertTrue(torch.allclose(logits, head(states, local, changed, mask)))
|
||||||
|
with self.assertRaisesRegex(ValueError, "person features"):
|
||||||
|
head(states, local)
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"hidden_size": 8,
|
||||||
|
"num_input_layers": 4,
|
||||||
|
"layer_fusion": "concat-mlp",
|
||||||
|
"local_visual_dim": 6,
|
||||||
|
"local_audio_dim": 5,
|
||||||
|
"local_projection_dim": 3,
|
||||||
|
"local_fusion": "frame-set-attention",
|
||||||
|
"person_attention_dim": 4,
|
||||||
|
"person_attention_heads": 2,
|
||||||
|
"transition_auxiliary": True,
|
||||||
|
"dropout": 0,
|
||||||
|
}
|
||||||
|
restored = TurnPredictionHead(
|
||||||
|
**checkpoint_head_config({"head_config": config})
|
||||||
|
)
|
||||||
|
restored.load_state_dict(head.state_dict())
|
||||||
|
restored.eval()
|
||||||
|
self.assertTrue(torch.equal(restored(states, local, people, mask), logits))
|
||||||
|
|
||||||
def test_head_shapes_gradients_missing_and_roundtrip(self):
|
def test_head_shapes_gradients_missing_and_roundtrip(self):
|
||||||
states = torch.randn(3, 4, 8)
|
states = torch.randn(3, 4, 8)
|
||||||
for v, a in ((0, 0), (6, 0), (0, 5), (6, 5)):
|
for v, a in ((0, 0), (6, 0), (0, 5), (6, 5)):
|
||||||
@@ -338,7 +492,8 @@ class LocalTests(unittest.TestCase):
|
|||||||
np.save(root / "targets/Session_4.npy", targets)
|
np.save(root / "targets/Session_4.npy", targets)
|
||||||
data = _load_examples(root / "features", root / "targets", ["Session_4"], "open-stream-replay",
|
data = _load_examples(root / "features", root / "targets", ["Session_4"], "open-stream-replay",
|
||||||
"easycom", {"Session_4": {1, 4}}, root / "local", "audio")
|
"easycom", {"Session_4": {1, 4}}, root / "local", "audio")
|
||||||
self.assertTrue(torch.equal(data[-1][:, :5], side["audio"][[1, 4]]))
|
self.assertTrue(torch.equal(data[-2][:, :5], side["audio"][[1, 4]]))
|
||||||
|
self.assertIsNone(data[-1])
|
||||||
self.assertTrue(torch.equal(data[0], reference["states"][[1, 4]]))
|
self.assertTrue(torch.equal(data[0], reference["states"][[1, 4]]))
|
||||||
self.assertTrue(torch.equal(data[1][:, 0], torch.from_numpy(targets[[2, 5]])))
|
self.assertTrue(torch.equal(data[1][:, 0], torch.from_numpy(targets[[2, 5]])))
|
||||||
|
|
||||||
@@ -562,7 +717,7 @@ class RoundTripTests(unittest.TestCase):
|
|||||||
np.save(root / "targets" / f"{name}.npy", targets)
|
np.save(root / "targets" / f"{name}.npy", targets)
|
||||||
common = ["--feature-root", str(root / "features"), "--target-root", str(root / "targets"),
|
common = ["--feature-root", str(root / "features"), "--target-root", str(root / "targets"),
|
||||||
"--device", "cpu", "--no-progress", "--expected-mode", "open-stream-replay"]
|
"--device", "cpu", "--no-progress", "--expected-mode", "open-stream-replay"]
|
||||||
for mode in ("baseline", "audio", "visual", "av", "ego4d"):
|
for mode in ("baseline", "audio", "visual", "av", "frame-set", "ego4d"):
|
||||||
checkpoint = root / f"{mode}.pth"
|
checkpoint = root / f"{mode}.pth"
|
||||||
train = [sys.executable, str(repo / "train_turn_head.py"), *common,
|
train = [sys.executable, str(repo / "train_turn_head.py"), *common,
|
||||||
"--epochs", "1", "--warmup-epochs", "0", "--batch-size", "4",
|
"--epochs", "1", "--warmup-epochs", "0", "--batch-size", "4",
|
||||||
@@ -579,8 +734,15 @@ class RoundTripTests(unittest.TestCase):
|
|||||||
train += ["--dev-sessions", "5", "--local-projection-dim", "0"]
|
train += ["--dev-sessions", "5", "--local-projection-dim", "0"]
|
||||||
if mode == "av":
|
if mode == "av":
|
||||||
train += ["--transition-objective", "r1-supervised"]
|
train += ["--transition-objective", "r1-supervised"]
|
||||||
|
if mode == "frame-set":
|
||||||
|
train += [
|
||||||
|
"--local-fusion", "frame-set-attention",
|
||||||
|
"--person-attention-dim", "4",
|
||||||
|
"--person-attention-heads", "2",
|
||||||
|
]
|
||||||
if mode != "baseline":
|
if mode != "baseline":
|
||||||
train += ["--local-feature-root", str(root / "local"), "--local-modalities", "av" if mode == "ego4d" else mode]
|
modality = "av" if mode in ("ego4d", "frame-set") else mode
|
||||||
|
train += ["--local-feature-root", str(root / "local"), "--local-modalities", modality]
|
||||||
infer += ["--local-feature-root", str(root / "local")]
|
infer += ["--local-feature-root", str(root / "local")]
|
||||||
for command in (train, infer):
|
for command in (train, infer):
|
||||||
completed = subprocess.run(command, cwd=repo, env=env, capture_output=True, text=True, timeout=60)
|
completed = subprocess.run(command, cwd=repo, env=env, capture_output=True, text=True, timeout=60)
|
||||||
@@ -590,6 +752,8 @@ class RoundTripTests(unittest.TestCase):
|
|||||||
self.assertEqual(bool(metrics["local_contract"]), mode != "baseline")
|
self.assertEqual(bool(metrics["local_contract"]), mode != "baseline")
|
||||||
if mode == "av":
|
if mode == "av":
|
||||||
self.assertTrue(metrics["transition_auxiliary"])
|
self.assertTrue(metrics["transition_auxiliary"])
|
||||||
|
if mode == "frame-set":
|
||||||
|
self.assertEqual(metrics["local_fusion"], "frame-set-attention")
|
||||||
# The reporting command uses the same metric keys as inference.
|
# The reporting command uses the same metric keys as inference.
|
||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
[sys.executable, str(repo / "summarize_local_baselines.py"), "--root", str(root)],
|
[sys.executable, str(repo / "summarize_local_baselines.py"), "--root", str(root)],
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from easycom_head import (
|
|||||||
CLASS_NAMES,
|
CLASS_NAMES,
|
||||||
DATASET_NAMES,
|
DATASET_NAMES,
|
||||||
LAYER_FUSION_MODES,
|
LAYER_FUSION_MODES,
|
||||||
|
LOCAL_FUSION_MODES,
|
||||||
LOSS_NAMES,
|
LOSS_NAMES,
|
||||||
NUM_HORIZONS,
|
NUM_HORIZONS,
|
||||||
OFFICIAL_TRAIN_SESSIONS,
|
OFFICIAL_TRAIN_SESSIONS,
|
||||||
@@ -46,6 +47,7 @@ from easycom_head import (
|
|||||||
transition_targets_from_activity,
|
transition_targets_from_activity,
|
||||||
validate_disjoint_sessions,
|
validate_disjoint_sessions,
|
||||||
)
|
)
|
||||||
|
from local_turn_features import FramePersonFeatures
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
@@ -58,10 +60,18 @@ def parse_args() -> argparse.Namespace:
|
|||||||
help="Optional frozen local-feature sidecars; omit for the original baseline.",
|
help="Optional frozen local-feature sidecars; omit for the original baseline.",
|
||||||
)
|
)
|
||||||
parser.add_argument("--local-modalities", choices=("visual", "audio", "av"), default="av")
|
parser.add_argument("--local-modalities", choices=("visual", "audio", "av"), default="av")
|
||||||
|
parser.add_argument(
|
||||||
|
"--local-fusion",
|
||||||
|
choices=LOCAL_FUSION_MODES,
|
||||||
|
default="pooled",
|
||||||
|
help="pooled keeps the existing baseline; frame-set-attention queries the latest causal person set.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--local-projection-dim", type=int, default=256,
|
"--local-projection-dim", type=int, default=256,
|
||||||
help="Trainable projection per local modality; 0 retains encoder dimensions.",
|
help="Trainable projection per local modality; 0 retains encoder dimensions.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument("--person-attention-dim", type=int, default=256)
|
||||||
|
parser.add_argument("--person-attention-heads", type=int, default=4)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--dataset",
|
"--dataset",
|
||||||
choices=DATASET_NAMES,
|
choices=DATASET_NAMES,
|
||||||
@@ -165,6 +175,17 @@ def parse_args() -> argparse.Namespace:
|
|||||||
def _validate_args(args: argparse.Namespace) -> None:
|
def _validate_args(args: argparse.Namespace) -> None:
|
||||||
if args.local_projection_dim < 0:
|
if args.local_projection_dim < 0:
|
||||||
raise ValueError("--local-projection-dim must be non-negative")
|
raise ValueError("--local-projection-dim must be non-negative")
|
||||||
|
if args.person_attention_dim <= 0 or args.person_attention_heads <= 0:
|
||||||
|
raise ValueError("Person attention dimension/heads must be positive")
|
||||||
|
if args.person_attention_dim % args.person_attention_heads:
|
||||||
|
raise ValueError("--person-attention-dim must be divisible by --person-attention-heads")
|
||||||
|
if args.local_fusion == "frame-set-attention" and (
|
||||||
|
args.local_feature_root is None or args.local_modalities != "av"
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"--local-fusion frame-set-attention requires --local-feature-root "
|
||||||
|
"and --local-modalities av"
|
||||||
|
)
|
||||||
if args.epochs <= 0:
|
if args.epochs <= 0:
|
||||||
raise ValueError("--epochs must be positive")
|
raise ValueError("--epochs must be positive")
|
||||||
if args.batch_size <= 0:
|
if args.batch_size <= 0:
|
||||||
@@ -225,6 +246,7 @@ def _load_examples(
|
|||||||
local_modalities: str = "av",
|
local_modalities: str = "av",
|
||||||
local_contract: dict[str, Any] | None = None,
|
local_contract: dict[str, Any] | None = None,
|
||||||
compute_transition_targets: bool = True,
|
compute_transition_targets: bool = True,
|
||||||
|
local_fusion: str = "pooled",
|
||||||
) -> tuple[
|
) -> tuple[
|
||||||
torch.Tensor,
|
torch.Tensor,
|
||||||
torch.Tensor,
|
torch.Tensor,
|
||||||
@@ -233,11 +255,13 @@ def _load_examples(
|
|||||||
int,
|
int,
|
||||||
list[dict[str, Any]],
|
list[dict[str, Any]],
|
||||||
torch.Tensor,
|
torch.Tensor,
|
||||||
|
FramePersonFeatures | None,
|
||||||
]:
|
]:
|
||||||
all_states: list[torch.Tensor] = []
|
all_states: list[torch.Tensor] = []
|
||||||
all_targets: list[torch.Tensor] = []
|
all_targets: list[torch.Tensor] = []
|
||||||
all_transition_targets: list[torch.Tensor] = []
|
all_transition_targets: list[torch.Tensor] = []
|
||||||
all_local: list[torch.Tensor] = []
|
all_local: list[torch.Tensor] = []
|
||||||
|
all_people: list[FramePersonFeatures] = []
|
||||||
metadata: list[dict[str, Any]] = []
|
metadata: list[dict[str, Any]] = []
|
||||||
hidden_size: int | None = None
|
hidden_size: int | None = None
|
||||||
num_input_layers: int | None = None
|
num_input_layers: int | None = None
|
||||||
@@ -251,6 +275,7 @@ def _load_examples(
|
|||||||
local_feature_root=local_feature_root,
|
local_feature_root=local_feature_root,
|
||||||
local_modalities=local_modalities,
|
local_modalities=local_modalities,
|
||||||
local_contract=local_contract,
|
local_contract=local_contract,
|
||||||
|
local_fusion=local_fusion,
|
||||||
)
|
)
|
||||||
local_contract = data.feature_metadata.get("local_contract")
|
local_contract = data.feature_metadata.get("local_contract")
|
||||||
states, targets, future_indices = data.future_examples(NUM_HORIZONS)
|
states, targets, future_indices = data.future_examples(NUM_HORIZONS)
|
||||||
@@ -260,6 +285,11 @@ def _load_examples(
|
|||||||
if data.local_features is not None
|
if data.local_features is not None
|
||||||
else torch.empty(len(states), 0)
|
else torch.empty(len(states), 0)
|
||||||
)
|
)
|
||||||
|
people = (
|
||||||
|
data.local_person_features.select_rows(current_indices)
|
||||||
|
if data.local_person_features is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
if compute_transition_targets:
|
if compute_transition_targets:
|
||||||
activity_sequence = torch.cat(
|
activity_sequence = torch.cat(
|
||||||
(data.targets[current_indices, None, :], targets),
|
(data.targets[current_indices, None, :], targets),
|
||||||
@@ -286,6 +316,8 @@ def _load_examples(
|
|||||||
targets = targets[keep]
|
targets = targets[keep]
|
||||||
transition_targets = transition_targets[keep]
|
transition_targets = transition_targets[keep]
|
||||||
local = local[keep]
|
local = local[keep]
|
||||||
|
if people is not None:
|
||||||
|
people = people.select_rows(torch.nonzero(keep).flatten())
|
||||||
if not len(states):
|
if not len(states):
|
||||||
raise ValueError(f"Sparse anchor selection kept no examples for {name}")
|
raise ValueError(f"Sparse anchor selection kept no examples for {name}")
|
||||||
if hidden_size is None:
|
if hidden_size is None:
|
||||||
@@ -306,6 +338,8 @@ def _load_examples(
|
|||||||
all_targets.append(targets)
|
all_targets.append(targets)
|
||||||
all_transition_targets.append(transition_targets)
|
all_transition_targets.append(transition_targets)
|
||||||
all_local.append(local)
|
all_local.append(local)
|
||||||
|
if people is not None:
|
||||||
|
all_people.append(people)
|
||||||
metadata.append(
|
metadata.append(
|
||||||
{
|
{
|
||||||
"session": name,
|
"session": name,
|
||||||
@@ -332,6 +366,8 @@ def _load_examples(
|
|||||||
)
|
)
|
||||||
if hidden_size is None or num_input_layers is None:
|
if hidden_size is None or num_input_layers is None:
|
||||||
raise ValueError("No sessions selected")
|
raise ValueError("No sessions selected")
|
||||||
|
if all_people and len(all_people) != len(all_states):
|
||||||
|
raise ValueError("Person sets are missing for some sessions")
|
||||||
return (
|
return (
|
||||||
torch.cat(all_states, dim=0),
|
torch.cat(all_states, dim=0),
|
||||||
torch.cat(all_targets, dim=0),
|
torch.cat(all_targets, dim=0),
|
||||||
@@ -340,6 +376,7 @@ def _load_examples(
|
|||||||
num_input_layers,
|
num_input_layers,
|
||||||
metadata,
|
metadata,
|
||||||
torch.cat(all_local, dim=0),
|
torch.cat(all_local, dim=0),
|
||||||
|
FramePersonFeatures.concatenate(all_people) if all_people else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -407,6 +444,7 @@ def _evaluate_valid_horizons(
|
|||||||
batch_size: int,
|
batch_size: int,
|
||||||
loss_name: str,
|
loss_name: str,
|
||||||
local: torch.Tensor | None = None,
|
local: torch.Tensor | None = None,
|
||||||
|
people: FramePersonFeatures | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
head.eval()
|
head.eval()
|
||||||
chunks: list[torch.Tensor] = []
|
chunks: list[torch.Tensor] = []
|
||||||
@@ -420,7 +458,19 @@ def _evaluate_valid_horizons(
|
|||||||
local[start : start + batch_size].to(device=device, dtype=torch.float32)
|
local[start : start + batch_size].to(device=device, dtype=torch.float32)
|
||||||
if local is not None else None
|
if local is not None else None
|
||||||
)
|
)
|
||||||
chunks.append(marginal_probabilities_from_logits(head(x, side), loss_name).cpu())
|
person_batch = None
|
||||||
|
person_mask = None
|
||||||
|
if people is not None:
|
||||||
|
rows = torch.arange(start, min(start + batch_size, len(states)))
|
||||||
|
person_batch, person_mask = people.padded(rows)
|
||||||
|
person_batch = person_batch.to(device=device, dtype=torch.float32)
|
||||||
|
person_mask = person_mask.to(device=device)
|
||||||
|
chunks.append(
|
||||||
|
marginal_probabilities_from_logits(
|
||||||
|
head(x, side, person_batch, person_mask),
|
||||||
|
loss_name,
|
||||||
|
).cpu()
|
||||||
|
)
|
||||||
probabilities = torch.cat(chunks, dim=0).numpy()
|
probabilities = torch.cat(chunks, dim=0).numpy()
|
||||||
target_array = targets.numpy()
|
target_array = targets.numpy()
|
||||||
|
|
||||||
@@ -455,7 +505,7 @@ def _checkpoint(
|
|||||||
history: list[dict[str, Any]],
|
history: list[dict[str, Any]],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"format_version": 4,
|
"format_version": 5,
|
||||||
"head": head.state_dict(),
|
"head": head.state_dict(),
|
||||||
"head_config": {
|
"head_config": {
|
||||||
"hidden_size": hidden_size,
|
"hidden_size": hidden_size,
|
||||||
@@ -469,6 +519,9 @@ def _checkpoint(
|
|||||||
"local_visual_dim": head.local_visual_dim,
|
"local_visual_dim": head.local_visual_dim,
|
||||||
"local_audio_dim": head.local_audio_dim,
|
"local_audio_dim": head.local_audio_dim,
|
||||||
"local_projection_dim": head.local_projection_dim,
|
"local_projection_dim": head.local_projection_dim,
|
||||||
|
"local_fusion": head.local_fusion,
|
||||||
|
"person_attention_dim": head.person_attention_dim,
|
||||||
|
"person_attention_heads": head.person_attention_heads,
|
||||||
},
|
},
|
||||||
"local_contract": feature_metadata[0].get("local_contract"),
|
"local_contract": feature_metadata[0].get("local_contract"),
|
||||||
"alignment": {
|
"alignment": {
|
||||||
@@ -513,7 +566,10 @@ def _checkpoint(
|
|||||||
"layer_fusion": args.layer_fusion,
|
"layer_fusion": args.layer_fusion,
|
||||||
"local_feature_root": str(args.local_feature_root) if args.local_feature_root else None,
|
"local_feature_root": str(args.local_feature_root) if args.local_feature_root else None,
|
||||||
"local_modalities": args.local_modalities if args.local_feature_root else None,
|
"local_modalities": args.local_modalities if args.local_feature_root else None,
|
||||||
|
"local_fusion": args.local_fusion,
|
||||||
"local_projection_dim": args.local_projection_dim,
|
"local_projection_dim": args.local_projection_dim,
|
||||||
|
"person_attention_dim": args.person_attention_dim,
|
||||||
|
"person_attention_heads": args.person_attention_heads,
|
||||||
"learnable_layer_mixture": args.layer_fusion == "scalar-mixture",
|
"learnable_layer_mixture": args.layer_fusion == "scalar-mixture",
|
||||||
"supervision_policy": (
|
"supervision_policy": (
|
||||||
"transition-sparse-manifest"
|
"transition-sparse-manifest"
|
||||||
@@ -605,6 +661,7 @@ def main() -> None:
|
|||||||
num_input_layers,
|
num_input_layers,
|
||||||
feature_metadata,
|
feature_metadata,
|
||||||
local_features,
|
local_features,
|
||||||
|
person_features,
|
||||||
) = _load_examples(
|
) = _load_examples(
|
||||||
args.feature_root,
|
args.feature_root,
|
||||||
args.target_root,
|
args.target_root,
|
||||||
@@ -615,6 +672,7 @@ def main() -> None:
|
|||||||
args.local_feature_root,
|
args.local_feature_root,
|
||||||
args.local_modalities,
|
args.local_modalities,
|
||||||
compute_transition_targets=args.transition_objective != "none",
|
compute_transition_targets=args.transition_objective != "none",
|
||||||
|
local_fusion=args.local_fusion,
|
||||||
)
|
)
|
||||||
local_contract = feature_metadata[0].get("local_contract")
|
local_contract = feature_metadata[0].get("local_contract")
|
||||||
if not len(states):
|
if not len(states):
|
||||||
@@ -645,6 +703,7 @@ def main() -> None:
|
|||||||
dev_states: torch.Tensor | None = None
|
dev_states: torch.Tensor | None = None
|
||||||
dev_targets: torch.Tensor | None = None
|
dev_targets: torch.Tensor | None = None
|
||||||
dev_local: torch.Tensor | None = None
|
dev_local: torch.Tensor | None = None
|
||||||
|
dev_people: FramePersonFeatures | None = None
|
||||||
if dev_sessions:
|
if dev_sessions:
|
||||||
(
|
(
|
||||||
dev_states,
|
dev_states,
|
||||||
@@ -654,6 +713,7 @@ def main() -> None:
|
|||||||
dev_num_input_layers,
|
dev_num_input_layers,
|
||||||
_,
|
_,
|
||||||
dev_local,
|
dev_local,
|
||||||
|
dev_people,
|
||||||
) = _load_examples(
|
) = _load_examples(
|
||||||
args.feature_root,
|
args.feature_root,
|
||||||
args.target_root,
|
args.target_root,
|
||||||
@@ -665,6 +725,7 @@ def main() -> None:
|
|||||||
args.local_modalities,
|
args.local_modalities,
|
||||||
local_contract,
|
local_contract,
|
||||||
compute_transition_targets=args.transition_objective != "none",
|
compute_transition_targets=args.transition_objective != "none",
|
||||||
|
local_fusion=args.local_fusion,
|
||||||
)
|
)
|
||||||
if dev_hidden_size != hidden_size:
|
if dev_hidden_size != hidden_size:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -678,7 +739,13 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
print(f"development examples: {len(dev_states)}")
|
print(f"development examples: {len(dev_states)}")
|
||||||
|
|
||||||
dataset = TensorDataset(states, targets, transition_targets, local_features)
|
dataset = TensorDataset(
|
||||||
|
states,
|
||||||
|
targets,
|
||||||
|
transition_targets,
|
||||||
|
local_features,
|
||||||
|
torch.arange(len(states), dtype=torch.long),
|
||||||
|
)
|
||||||
generator = torch.Generator().manual_seed(args.seed)
|
generator = torch.Generator().manual_seed(args.seed)
|
||||||
loader = DataLoader(
|
loader = DataLoader(
|
||||||
dataset,
|
dataset,
|
||||||
@@ -702,13 +769,26 @@ def main() -> None:
|
|||||||
layer_fusion=args.layer_fusion,
|
layer_fusion=args.layer_fusion,
|
||||||
transition_auxiliary=args.transition_objective != "none",
|
transition_auxiliary=args.transition_objective != "none",
|
||||||
local_projection_dim=args.local_projection_dim,
|
local_projection_dim=args.local_projection_dim,
|
||||||
|
local_fusion=args.local_fusion,
|
||||||
|
person_attention_dim=args.person_attention_dim,
|
||||||
|
person_attention_heads=args.person_attention_heads,
|
||||||
**local_head_dimensions(local_contract),
|
**local_head_dimensions(local_contract),
|
||||||
).to(device)
|
).to(device)
|
||||||
if local_contract is not None:
|
if local_contract is not None:
|
||||||
print(
|
print(
|
||||||
f"local fusion: {args.local_modalities}; raw V={head.local_visual_dim}, "
|
f"local fusion: {args.local_fusion}/{args.local_modalities}; "
|
||||||
f"A={head.local_audio_dim}; projection={args.local_projection_dim or 'identity'}"
|
f"raw V={head.local_visual_dim}, A={head.local_audio_dim}; "
|
||||||
|
f"projection={args.local_projection_dim or 'identity'}"
|
||||||
)
|
)
|
||||||
|
if person_features is not None:
|
||||||
|
print(
|
||||||
|
"frame sets: "
|
||||||
|
f"{len(person_features.frame_offsets) - 1} source frames, "
|
||||||
|
f"{len(person_features.features)} person observations, "
|
||||||
|
f"max_people={person_features.max_people}, "
|
||||||
|
f"attention={args.person_attention_dim}D/"
|
||||||
|
f"{args.person_attention_heads} heads"
|
||||||
|
)
|
||||||
trainable_parameters = sum(
|
trainable_parameters = sum(
|
||||||
parameter.numel() for parameter in head.parameters() if parameter.requires_grad
|
parameter.numel() for parameter in head.parameters() if parameter.requires_grad
|
||||||
)
|
)
|
||||||
@@ -782,7 +862,13 @@ def main() -> None:
|
|||||||
unit="batch",
|
unit="batch",
|
||||||
disable=args.no_progress,
|
disable=args.no_progress,
|
||||||
)
|
)
|
||||||
for state_batch, target_batch, transition_target_batch, local_batch in progress:
|
for (
|
||||||
|
state_batch,
|
||||||
|
target_batch,
|
||||||
|
transition_target_batch,
|
||||||
|
local_batch,
|
||||||
|
example_index_batch,
|
||||||
|
) in progress:
|
||||||
local_batch = local_batch.to(
|
local_batch = local_batch.to(
|
||||||
device=device, dtype=torch.float32, non_blocking=True,
|
device=device, dtype=torch.float32, non_blocking=True,
|
||||||
)
|
)
|
||||||
@@ -801,15 +887,37 @@ def main() -> None:
|
|||||||
dtype=torch.float32,
|
dtype=torch.float32,
|
||||||
non_blocking=True,
|
non_blocking=True,
|
||||||
)
|
)
|
||||||
|
person_batch = None
|
||||||
|
person_mask = None
|
||||||
|
if person_features is not None:
|
||||||
|
person_batch, person_mask = person_features.padded(
|
||||||
|
example_index_batch
|
||||||
|
)
|
||||||
|
person_batch = person_batch.to(
|
||||||
|
device=device,
|
||||||
|
dtype=torch.float32,
|
||||||
|
non_blocking=True,
|
||||||
|
)
|
||||||
|
person_mask = person_mask.to(device=device, non_blocking=True)
|
||||||
optimizer.zero_grad(set_to_none=True)
|
optimizer.zero_grad(set_to_none=True)
|
||||||
if args.transition_objective == "none":
|
if args.transition_objective == "none":
|
||||||
logits = head(state_batch, local_batch)
|
logits = head(
|
||||||
|
state_batch,
|
||||||
|
local_batch,
|
||||||
|
person_batch,
|
||||||
|
person_mask,
|
||||||
|
)
|
||||||
activity_loss = loss_function(logits, target_batch)
|
activity_loss = loss_function(logits, target_batch)
|
||||||
loss = activity_loss
|
loss = activity_loss
|
||||||
transition_loss = activity_loss.detach().new_zeros(())
|
transition_loss = activity_loss.detach().new_zeros(())
|
||||||
expected_reward = activity_loss.detach().new_zeros(())
|
expected_reward = activity_loss.detach().new_zeros(())
|
||||||
else:
|
else:
|
||||||
logits, transition_logits = head.forward_with_transition(state_batch, local_batch)
|
logits, transition_logits = head.forward_with_transition(
|
||||||
|
state_batch,
|
||||||
|
local_batch,
|
||||||
|
person_batch,
|
||||||
|
person_mask,
|
||||||
|
)
|
||||||
activity_loss = loss_function(logits, target_batch)
|
activity_loss = loss_function(logits, target_batch)
|
||||||
components = transition_aware_objective(
|
components = transition_aware_objective(
|
||||||
activity_loss,
|
activity_loss,
|
||||||
@@ -890,6 +998,7 @@ def main() -> None:
|
|||||||
args.batch_size,
|
args.batch_size,
|
||||||
args.loss,
|
args.loss,
|
||||||
dev_local,
|
dev_local,
|
||||||
|
dev_people,
|
||||||
)
|
)
|
||||||
dev_map = float(dev_metrics["mean_map"])
|
dev_map = float(dev_metrics["mean_map"])
|
||||||
record["dev_valid_mean_map"] = dev_map
|
record["dev_valid_mean_map"] = dev_map
|
||||||
|
|||||||
Reference in New Issue
Block a user