Fix Ego4D target compatibility in local baselines

This commit is contained in:
Xin Wang
2026-08-28 15:03:31 +08:00
parent 4abbde4f52
commit 0f6a72cfdf
5 changed files with 134 additions and 16 deletions

View File

@@ -291,6 +291,13 @@ python run_ego4d_local_baselines.py "${EXP_ARGS[@]}" --seeds 1 2
如果原 stacked baseline 使用 fixed-mean,可在四组统一加 `--layer-fusion fixed-mean`。 如果原 stacked baseline 使用 fixed-mean,可在四组统一加 `--layer-fusion fixed-mean`。
不要拿不同 epoch/layer-fusion 的旧 baseline 直接计算新增分支的增量。 不要拿不同 epoch/layer-fusion 的旧 baseline 直接计算新增分支的增量。
Ego4D target 由 EgoSpeak/LSTR 的 `MCE` 当作 binary/multi-positive 权重直接读取;
因此代码对 `dataset=ego4d` 保留其原始二值行,不强行套用 EasyCom 的四状态约束,
也绝不把 `[0,0,0]` 等行擅自改成 `[1,0,0]` background。预检会汇总并打印所有
非 canonical pattern,便于核对数据。EasyCom 仍严格限制为四个 activity states。
当前四组实验关闭 transition objective,因此不会从非 canonical 行伪造 transition
标签;如果以后启用 transition auxiliary,需要先定义语义和显式 valid mask。
预检不会一次把 433 条全局特征全部装进 RAM;实际训练仍沿用原训练器,会加载整个 train split。 预检不会一次把 433 条全局特征全部装进 RAM;实际训练仍沿用原训练器,会加载整个 train split。
last4 特征可能需要数十 GB 主机内存。多卡并行 head 训练也会复制主机内存,不是只看 GPU 是否空闲。 last4 特征可能需要数十 GB 主机内存。多卡并行 head 训练也会复制主机内存,不是只看 GPU 是否空闲。

View File

@@ -278,8 +278,18 @@ def _validate_loss_inputs(
) )
def _validated_binary_activity_targets(targets: torch.Tensor) -> torch.Tensor: def _validated_binary_activity_targets(
"""Validate the four legal EasyCom/EgoSpeak three-label activity rows.""" 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: if targets.ndim < 1 or targets.shape[-1] != NUM_CLASSES:
raise ValueError( raise ValueError(
@@ -291,11 +301,22 @@ def _validated_binary_activity_targets(targets: torch.Tensor) -> torch.Tensor:
rounded = targets.round() rounded = targets.round()
if not torch.allclose(targets, rounded, atol=1e-6, rtol=0.0): if not torch.allclose(targets, rounded, atol=1e-6, rtol=0.0):
raise ValueError("Activity targets must contain only binary 0/1 values") raise ValueError("Activity targets must contain only binary 0/1 values")
expected_background = (rounded[..., 1:].sum(dim=-1) == 0).to(rounded.dtype) expected_background = rounded[..., 1:].sum(dim=-1) == 0
if not torch.equal(rounded[..., 0], expected_background): 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( raise ValueError(
"Activity targets must be one of [1,0,0], [0,1,0], " "Activity targets must be one of [1,0,0], [0,1,0], "
"[0,0,1], or [0,1,1]" "[0,0,1], or [0,1,1]; observed invalid "
f"row(s): {invalid_rows}"
) )
return rounded return rounded
@@ -799,7 +820,13 @@ def load_session_data(
f"{target_path}" f"{target_path}"
) )
targets = torch.from_numpy(np.asarray(target_array, dtype=np.float32)) targets = torch.from_numpy(np.asarray(target_array, dtype=np.float32))
_validated_binary_activity_targets(targets) 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): if len(label_indices) and int(label_indices[-1]) >= len(targets):
raise ValueError( raise ValueError(
f"Last label index {int(label_indices[-1])} exceeds target length " f"Last label index {int(label_indices[-1])} exceeds target length "
@@ -823,6 +850,20 @@ def load_session_data(
"video_sampling_grid", "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)
}
local = None local = None
if local_feature_root is not None: if local_feature_root is not None:
from local_turn_features import load_local_features from local_turn_features import load_local_features

View File

@@ -93,6 +93,8 @@ def preflight(args) -> str:
shape, contract = None, None shape, contract = None, None
counts = {"visual": [0, 0], "audio": [0, 0]} counts = {"visual": [0, 0], "audio": [0, 0]}
total = 0 total = 0
noncanonical_target_rows = 0
target_row_pattern_counts: dict[str, int] = {}
for index, name in enumerate(names, 1): for index, name in enumerate(names, 1):
data = load_session_data( data = load_session_data(
args.feature_root, args.target_root, name, dataset="ego4d", args.feature_root, args.target_root, name, dataset="ego4d",
@@ -117,6 +119,15 @@ def preflight(args) -> str:
counts[modality][1] += len(data.states) counts[modality][1] += len(data.states)
offset += width + 1 offset += width + 1
total += len(data.states) total += len(data.states)
noncanonical_target_rows += int(
data.feature_metadata.get("num_noncanonical_target_rows", 0)
)
for pattern, count in data.feature_metadata.get(
"target_row_pattern_counts", {}
).items():
target_row_pattern_counts[pattern] = (
target_row_pattern_counts.get(pattern, 0) + int(count)
)
del data # Validate one clip at a time, including all test clips. del data # Validate one clip at a time, including all test clips.
if not args.no_progress and (index == 1 or index % 25 == 0 or index == len(names)): if not args.no_progress and (index == 1 or index % 25 == 0 or index == len(names)):
print(f"preflight: {index}/{len(names)} clips", flush=True) print(f"preflight: {index}/{len(names)} clips", flush=True)
@@ -128,6 +139,19 @@ def preflight(args) -> str:
if fusion != "single" and len(shape) != 2: if fusion != "single" and len(shape) != 2:
raise ValueError(f"--layer-fusion {fusion} requires stacked [T,L,D] global states") raise ValueError(f"--layer-fusion {fusion} requires stacked [T,L,D] global states")
print(f"Validated {total} states; global shape per row={shape}; fusion={fusion}", flush=True) print(f"Validated {total} states; global shape per row={shape}; fusion={fusion}", flush=True)
if noncanonical_target_rows:
canonical_patterns = {"100", "010", "001", "011"}
details = ", ".join(
f"[{','.join(pattern)}]={count}"
for pattern, count in sorted(target_row_pattern_counts.items())
if pattern not in canonical_patterns
)
print(
"Ego4D binary target rows outside the EasyCom four-state "
f"constraint: {noncanonical_target_rows}/{total} ({details}); "
"preserved exactly for the official LSTR MCE objective.",
flush=True,
)
for modality, (valid, count) in counts.items(): for modality, (valid, count) in counts.items():
if count: if count:
note = "; provided boxes, not an online detector" if modality == "visual" else "" note = "; provided boxes, not an online detector" if modality == "visual" else ""

View File

@@ -181,6 +181,30 @@ class Ego4DLocalWorkflowTests(unittest.TestCase):
if name != "baseline_seed0": if name != "baseline_seed0":
self.assertEqual(train[train.index("--local-modalities") + 1], name.split("_")[0]) self.assertEqual(train[train.index("--local-modalities") + 1], name.split("_")[0])
def test_ego4d_noncanonical_binary_rows_are_preserved(self):
target_path = self.root / "targets" / f"{self.names[0]}.npy"
targets = np.load(target_path)
targets[3] = 0
targets[4] = [1, 1, 0]
np.save(target_path, targets)
data = load_session_data(
self.root / "features",
self.root / "targets",
self.names[0],
dataset="ego4d",
expected_mode="open-stream-replay",
)
self.assertTrue(torch.equal(data.targets[3], torch.zeros(3)))
self.assertTrue(
torch.equal(data.targets[4], torch.tensor([1.0, 1.0, 0.0]))
)
self.assertEqual(data.feature_metadata["num_noncanonical_target_rows"], 2)
output = io.StringIO()
with contextlib.redirect_stdout(output):
runner.preflight(runner.parse_args(self.runner_args(["--variants", "baseline"])))
self.assertIn("constraint: 2/60 ([0,0,0]=1, [1,1,0]=1)", output.getvalue())
def test_family_preflight_catches_missing_test_sidecar_before_training(self): def test_family_preflight_catches_missing_test_sidecar_before_training(self):
(self.root / "local" / f"{self.names[1]}.pt").unlink() (self.root / "local" / f"{self.names[1]}.pt").unlink()
with patch("run_ego4d_local_baselines.subprocess.run") as run: with patch("run_ego4d_local_baselines.subprocess.run") as run:
@@ -219,6 +243,13 @@ class Ego4DLocalWorkflowTests(unittest.TestCase):
self.assertFalse((self.root / "checkpoints").exists()) self.assertFalse((self.root / "checkpoints").exists())
def test_full_cpu_family_round_trip_and_summary(self): def test_full_cpu_family_round_trip_and_summary(self):
# EgoSpeak's LSTR MCE consumes the released Ego4D rows as binary
# weights. Preserve noncanonical rows, and do not derive transitions
# when that auxiliary objective is disabled.
target_path = self.root / "targets" / f"{self.names[0]}.npy"
targets = np.load(target_path)
targets[3] = 0
np.save(target_path, targets)
self.split_feature_layout() self.split_feature_layout()
env = dict(os.environ, OMP_NUM_THREADS="1", MKL_NUM_THREADS="1", OPENBLAS_NUM_THREADS="1") env = dict(os.environ, OMP_NUM_THREADS="1", MKL_NUM_THREADS="1", OPENBLAS_NUM_THREADS="1")
completed = subprocess.run([sys.executable, str(runner.REPO / "run_ego4d_local_baselines.py"), completed = subprocess.run([sys.executable, str(runner.REPO / "run_ego4d_local_baselines.py"),

View File

@@ -224,6 +224,7 @@ def _load_examples(
local_feature_root: Path | None = None, local_feature_root: 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,
compute_transition_targets: bool = True,
) -> tuple[ ) -> tuple[
torch.Tensor, torch.Tensor,
torch.Tensor, torch.Tensor,
@@ -259,11 +260,19 @@ 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)
) )
activity_sequence = torch.cat( if compute_transition_targets:
(data.targets[current_indices, None, :], targets), activity_sequence = torch.cat(
dim=1, (data.targets[current_indices, None, :], targets),
) dim=1,
transition_targets = transition_targets_from_activity(activity_sequence) )
transition_targets = transition_targets_from_activity(
activity_sequence
)
else:
transition_targets = torch.zeros(
targets.shape[:-1],
dtype=targets.dtype,
)
dense_example_count = len(states) dense_example_count = len(states)
if selected_indices is not None: if selected_indices is not None:
if name not in selected_indices: if name not in selected_indices:
@@ -605,6 +614,7 @@ def main() -> None:
sparse_anchor_indices, sparse_anchor_indices,
args.local_feature_root, args.local_feature_root,
args.local_modalities, args.local_modalities,
compute_transition_targets=args.transition_objective != "none",
) )
local_contract = feature_metadata[0].get("local_contract") local_contract = feature_metadata[0].get("local_contract")
if not len(states): if not len(states):
@@ -613,11 +623,15 @@ def main() -> None:
f"training examples: {len(states)}, feature_dim={hidden_size}, " f"training examples: {len(states)}, feature_dim={hidden_size}, "
f"input_layers={num_input_layers}" f"input_layers={num_input_layers}"
) )
print( if args.transition_objective == "none":
"transition targets: " print("transition targets: disabled (not derived or loaded)")
f"{int(transition_targets.sum().item())}/{transition_targets.numel()} " else:
f"positive ({float(transition_targets.mean()) * 100:.3f}%)" print(
) "transition targets: "
f"{int(transition_targets.sum().item())}/"
f"{transition_targets.numel()} positive "
f"({float(transition_targets.mean()) * 100:.3f}%)"
)
if num_input_layers > 1 and args.layer_fusion == "single": if num_input_layers > 1 and args.layer_fusion == "single":
raise ValueError( raise ValueError(
f"Features contain {num_input_layers} stacked layers; pass " f"Features contain {num_input_layers} stacked layers; pass "
@@ -650,6 +664,7 @@ def main() -> None:
args.local_feature_root, args.local_feature_root,
args.local_modalities, args.local_modalities,
local_contract, local_contract,
compute_transition_targets=args.transition_objective != "none",
) )
if dev_hidden_size != hidden_size: if dev_hidden_size != hidden_size:
raise ValueError( raise ValueError(