Files
icassp2027-experiment/train_egospeak_omni_residual.py
T

632 lines
22 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Train stage-one frozen-EgoSpeak plus frozen-Omni residual fusion.
The released MiniROADA A+V checkpoint is loaded strictly and remains in eval
mode with gradients disabled. Existing causal 5 Hz Omni states are fused with
the MiniROADA GRU state, and only a zero-initialized residual anticipation head
is optimized. Evaluation happens once after the requested fixed epoch budget;
the script saves both fused and exactly matched frozen-base predictions.
"""
from __future__ import annotations
import argparse
import json
import math
import random
from collections.abc import Sequence
from contextlib import nullcontext
from pathlib import Path
from typing import Any
import numpy as np
import torch
from torch.utils.data import DataLoader
from tqdm.auto import tqdm
from easycom_head import (
CLASS_NAMES,
NUM_HORIZONS,
OFFICIAL_TEST_SESSIONS,
OFFICIAL_TRAIN_SESSIONS,
STEP_SECONDS,
average_precision_metrics,
)
from egospeak_omni_residual import (
EasyComSessionArrays,
EgoSpeakOmniResidual,
EgoSpeakOmniWindowDataset,
align_source_predictions,
load_easycom_session_arrays,
load_frozen_miniroada,
miniroad_normalized_loss,
normalize_session_name,
sha256_file,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--egospeak-feature-root",
type=Path,
required=True,
help=(
"EasyCom root containing rgb_kinetics_resnet50/, "
"audio_wav2vec2/, and target_perframe/."
),
)
parser.add_argument(
"--omni-feature-root",
type=Path,
required=True,
help="Existing directory containing causal Session_X.pt Omni states.",
)
parser.add_argument(
"--base-checkpoint",
type=Path,
required=True,
help="Released EgoSpeak miniroad_easycom_AV.pth state dict.",
)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--train-sessions", nargs="+", default=None)
parser.add_argument("--eval-sessions", nargs="+", default=None)
parser.add_argument(
"--expected-omni-mode",
default="open-stream-replay",
help="Expected saved extraction mode; pass 'any' only for an audit run.",
)
parser.add_argument(
"--omni-layer-fusion",
choices=("single", "last", "mean"),
default="mean",
help="How to collapse saved [T,L,D] Thinker states.",
)
parser.add_argument("--epochs", type=int, default=4)
parser.add_argument("--batch-size", type=int, default=64)
parser.add_argument(
"--eval-chunk-size",
type=int,
default=0,
help=(
"GRU inference chunk size; 0 processes each session in one pass "
"to match released MiniROADA evaluation."
),
)
parser.add_argument("--num-workers", type=int, default=4)
parser.add_argument("--window-size", type=int, default=128)
parser.add_argument("--stride", type=int, default=4)
parser.add_argument("--fusion-dim", type=int, default=256)
parser.add_argument("--tail-embedding-dim", type=int, default=32)
parser.add_argument("--dropout", type=float, default=0.2)
parser.add_argument("--lr", type=float, default=1e-4)
parser.add_argument("--weight-decay", type=float, default=0.05)
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--device", default="cuda")
parser.add_argument("--amp", action="store_true")
parser.add_argument(
"--max-train-examples",
type=int,
help="Smoke-test cap; omit for a complete run.",
)
parser.add_argument(
"--reference-prediction-root",
type=Path,
help=(
"Optional released MiniROADA raw eval directory. The frozen base "
"is compared against Session_X.npy files before results are accepted."
),
)
parser.add_argument("--reference-tolerance", type=float, default=1e-5)
parser.add_argument("--no-progress", action="store_true")
return parser.parse_args()
def set_seed(seed: int) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.benchmark = False
torch.backends.cudnn.deterministic = True
def resolve_device(value: str) -> torch.device:
device = torch.device(value)
if device.type == "cuda" and not torch.cuda.is_available():
raise RuntimeError("CUDA was requested but is unavailable")
return device
def normalize_sessions(
values: Sequence[str] | None,
default: Sequence[str],
) -> list[str]:
sessions = [normalize_session_name(value) for value in (values or default)]
if len(sessions) != len(set(sessions)):
raise ValueError("Duplicate sessions were supplied")
return sessions
def load_sessions(
args: argparse.Namespace,
sessions: Sequence[str],
) -> list[EasyComSessionArrays]:
expected_mode = (
None if args.expected_omni_mode == "any" else args.expected_omni_mode
)
loaded = []
for session in sessions:
item = load_easycom_session_arrays(
args.egospeak_feature_root,
args.omni_feature_root,
session,
expected_omni_mode=expected_mode,
omni_layer_fusion=args.omni_layer_fusion,
)
loaded.append(item)
print(
f"{item.name}: steps={item.num_steps}, "
f"omni={tuple(item.omni.shape)}, "
f"mode={item.omni_metadata['mode']}"
)
dims = {int(item.omni.shape[-1]) for item in loaded}
if len(dims) != 1:
raise ValueError(f"Omni dimensions differ across sessions: {sorted(dims)}")
signatures = {
(
item.omni_metadata["model_id"],
item.omni_metadata["mode"],
item.omni_metadata["config"].get("memory_seconds"),
item.omni_metadata["config"].get("video_fps"),
item.omni_metadata["config"].get("readout_last_n_layers"),
item.omni_metadata["config"].get("readout_layer_fusion"),
tuple(item.omni_metadata["state_shape_before_layer_fusion"][1:]),
)
for item in loaded
}
if len(signatures) != 1:
raise ValueError(
"Selected sessions mix incompatible Omni extraction contracts: "
f"{sorted(map(str, signatures))}"
)
return loaded
def omni_contract_signature(item: EasyComSessionArrays) -> tuple[Any, ...]:
config = item.omni_metadata["config"]
return (
item.omni_metadata["model_id"],
item.omni_metadata["mode"],
config.get("memory_seconds"),
config.get("video_fps"),
config.get("readout_last_n_layers"),
config.get("readout_layer_fusion"),
tuple(item.omni_metadata["state_shape_before_layer_fusion"][1:]),
)
def autocast_context(device: torch.device, enabled: bool):
if not enabled:
return nullcontext()
if device.type != "cuda":
raise ValueError("--amp currently requires CUDA")
return torch.autocast(device_type="cuda", dtype=torch.float16)
def make_loader(
dataset: EgoSpeakOmniWindowDataset,
args: argparse.Namespace,
epoch: int,
) -> DataLoader:
generator = torch.Generator()
generator.manual_seed(args.seed + epoch)
return DataLoader(
dataset,
batch_size=args.batch_size,
shuffle=True,
num_workers=args.num_workers,
pin_memory=torch.cuda.is_available(),
persistent_workers=False,
generator=generator,
)
@torch.inference_mode()
def verify_zero_initialization(
model: EgoSpeakOmniResidual,
dataset: EgoSpeakOmniWindowDataset,
device: torch.device,
) -> float:
model.eval()
rgb, audio, omni, tail_bin, _, _, _ = dataset[0]
fused, base = model.forward_last(
rgb[None].to(device),
audio[None].to(device),
omni[None].to(device),
tail_bin[None].to(device),
)
maximum = float((fused - base).abs().max().cpu())
if maximum != 0.0:
raise RuntimeError(
"Zero-initialized residual does not exactly recover MiniROADA: "
f"max_abs_difference={maximum}"
)
print("Initialization audit: fused logits == frozen EgoSpeak logits exactly")
return maximum
def train_one_epoch(
model: EgoSpeakOmniResidual,
loader: DataLoader,
optimizer: torch.optim.Optimizer,
scaler: torch.cuda.amp.GradScaler,
device: torch.device,
*,
amp: bool,
show_progress: bool,
) -> dict[str, float]:
model.train()
total_loss = 0.0
total_examples = 0
iterator = tqdm(loader, desc="train", disable=not show_progress, dynamic_ncols=True)
for rgb, audio, omni, tail_bin, targets, _, _ in iterator:
rgb = rgb.to(device=device, dtype=torch.float32, non_blocking=True)
audio = audio.to(device=device, dtype=torch.float32, non_blocking=True)
omni = omni.to(device=device, dtype=torch.float32, non_blocking=True)
tail_bin = tail_bin.to(device=device, dtype=torch.long, non_blocking=True)
targets = targets.to(device=device, dtype=torch.float32, non_blocking=True)
optimizer.zero_grad(set_to_none=True)
with autocast_context(device, amp):
logits, _ = model.forward_last(rgb, audio, omni, tail_bin)
loss = miniroad_normalized_loss(logits.float(), targets.float())
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
batch_size = len(rgb)
total_examples += batch_size
total_loss += float(loss.detach().cpu()) * batch_size
iterator.set_postfix_str(f"loss={total_loss / total_examples:.5f}")
return {
"loss": total_loss / total_examples,
"num_examples": total_examples,
}
@torch.inference_mode()
def predict_session(
model: EgoSpeakOmniResidual,
session: EasyComSessionArrays,
device: torch.device,
*,
chunk_size: int,
amp: bool,
show_progress: bool,
) -> tuple[np.ndarray, np.ndarray]:
model.eval()
num_sources = session.num_steps - NUM_HORIZONS
if num_sources <= 0:
raise ValueError(f"{session.name}: no valid ten-horizon source rows")
if chunk_size == 0:
chunk_size = num_sources
fused_chunks: list[torch.Tensor] = []
base_chunks: list[torch.Tensor] = []
recurrent_state = None
starts = range(0, num_sources, chunk_size)
for start in tqdm(
starts,
total=math.ceil(num_sources / chunk_size),
desc=f"infer {session.name}",
disable=not show_progress,
dynamic_ncols=True,
):
end = min(start + chunk_size, num_sources)
rgb = torch.from_numpy(
np.asarray(session.rgb[start:end], dtype=np.float32).copy()
)[None].to(device)
audio = torch.from_numpy(
np.asarray(session.audio[start:end], dtype=np.float32).copy()
)[None].to(device)
omni = session.omni[start:end][None].to(device=device, dtype=torch.float32)
tail_bin = session.tail_bins[start:end][None].to(device)
with autocast_context(device, amp):
fused, base, recurrent_state = model.forward_sequence_chunk(
rgb,
audio,
omni,
tail_bin,
recurrent_state,
)
recurrent_state = recurrent_state.detach()
fused_chunks.append(torch.softmax(fused.float(), dim=-1)[0].cpu())
base_chunks.append(torch.softmax(base.float(), dim=-1)[0].cpu())
return torch.cat(fused_chunks).numpy(), torch.cat(base_chunks).numpy()
def evaluate_predictions(
predictions: dict[str, np.ndarray],
valid: dict[str, np.ndarray],
targets: dict[str, np.ndarray],
) -> dict[str, Any]:
full_results = []
causal_results = []
for horizon in range(NUM_HORIZONS):
full_scores = np.concatenate(
[item[:, :, horizon] for item in predictions.values()], axis=0
)
full_targets = np.concatenate(list(targets.values()), axis=0)
full = average_precision_metrics(full_scores, full_targets)
full.update(horizon=horizon + 1, seconds=(horizon + 1) * STEP_SECONDS)
full_results.append(full)
causal_scores = np.concatenate(
[
predictions[name][valid[name][:, horizon], :, horizon]
for name in predictions
],
axis=0,
)
causal_targets = np.concatenate(
[targets[name][valid[name][:, horizon]] for name in predictions],
axis=0,
)
causal = average_precision_metrics(causal_scores, causal_targets)
causal.update(horizon=horizon + 1, seconds=(horizon + 1) * STEP_SECONDS)
causal_results.append(causal)
return {
"egospeak_full_timeline": {
"horizons": full_results,
"mean_map": float(np.mean([item["map"] for item in full_results])),
},
"causal_valid_only": {
"horizons": causal_results,
"mean_map": float(np.mean([item["map"] for item in causal_results])),
},
}
def print_metrics(label: str, metrics: dict[str, Any]) -> None:
print(f"\n=== {label} ===")
for protocol_name in ("egospeak_full_timeline", "causal_valid_only"):
protocol = metrics[protocol_name]
print(f"{protocol_name}:")
for item in protocol["horizons"]:
class_text = ", ".join(
f"{name}={value * 100:.3f}" for name, value in item["class_ap"].items()
)
print(f" {item['seconds']:.1f}s mAP={item['map'] * 100:.3f}; {class_text}")
print(f" mean mAP={protocol['mean_map'] * 100:.3f}")
def prepare_output(path: Path) -> Path:
path = path.expanduser().resolve()
if path.exists() and any(path.iterdir()):
raise FileExistsError(f"Refusing to overwrite nonempty output: {path}")
path.mkdir(parents=True, exist_ok=True)
for name in (
"raw_eval",
"pred",
"valid",
"frozen_egospeak_raw_eval",
"frozen_egospeak_pred",
):
(path / name).mkdir()
return path
def main() -> None:
args = parse_args()
if args.epochs <= 0 or args.batch_size <= 0 or args.eval_chunk_size < 0:
raise ValueError(
"Epoch and batch sizes must be positive; evaluation chunk size "
"must be nonnegative"
)
if args.reference_tolerance < 0:
raise ValueError("--reference-tolerance must be nonnegative")
set_seed(args.seed)
device = resolve_device(args.device)
train_names = normalize_sessions(args.train_sessions, OFFICIAL_TRAIN_SESSIONS)
eval_names = normalize_sessions(args.eval_sessions, OFFICIAL_TEST_SESSIONS)
train_sessions = load_sessions(args, train_names)
eval_sessions = load_sessions(args, eval_names)
omni_dims = {int(item.omni.shape[-1]) for item in (*train_sessions, *eval_sessions)}
if len(omni_dims) != 1:
raise ValueError(f"Omni dimensions differ: {sorted(omni_dims)}")
contracts = {
omni_contract_signature(item) for item in (*train_sessions, *eval_sessions)
}
if len(contracts) != 1:
raise ValueError(
"Train/evaluation sessions use incompatible Omni extraction "
f"contracts: {sorted(map(str, contracts))}"
)
omni_dim = omni_dims.pop()
base = load_frozen_miniroada(args.base_checkpoint)
model = EgoSpeakOmniResidual(
base,
omni_dim=omni_dim,
fusion_dim=args.fusion_dim,
tail_embedding_dim=args.tail_embedding_dim,
dropout=args.dropout,
).to(device)
trainable = model.trainable_parameters()
print(f"device: {device}")
print(f"train sessions: {', '.join(train_names)}")
print(f"evaluation sessions: {', '.join(eval_names)}")
print(
f"parameters: total={sum(p.numel() for p in model.parameters()):,}, "
f"trainable={sum(p.numel() for p in trainable):,}, "
"MiniROADA frozen=True"
)
dataset = EgoSpeakOmniWindowDataset(
train_sessions,
window_size=args.window_size,
stride=args.stride,
seed=args.seed,
max_examples=args.max_train_examples,
)
initialization_difference = verify_zero_initialization(model, dataset, device)
output_dir = prepare_output(args.output_dir)
optimizer = torch.optim.AdamW(
trainable,
lr=args.lr,
weight_decay=args.weight_decay,
)
scaler = torch.cuda.amp.GradScaler(enabled=args.amp)
history = []
for epoch in range(1, args.epochs + 1):
dataset.resample(epoch - 1)
loader = make_loader(dataset, args, epoch)
result = train_one_epoch(
model,
loader,
optimizer,
scaler,
device,
amp=args.amp,
show_progress=not args.no_progress,
)
result["epoch"] = epoch
history.append(result)
print(
f"epoch {epoch}/{args.epochs}: loss={result['loss']:.6f}, "
f"examples={result['num_examples']}"
)
checkpoint_path = output_dir / "checkpoint_last.pt"
checkpoint_payload = {
"format": "egospeak-omni-zero-residual-v1",
"model_state_dict": model.state_dict(),
"model_config": {
"omni_dim": omni_dim,
"fusion_dim": args.fusion_dim,
"tail_embedding_dim": args.tail_embedding_dim,
"num_tail_bins": NUM_HORIZONS,
"dropout": args.dropout,
},
"base_checkpoint": str(args.base_checkpoint.expanduser().resolve()),
"base_checkpoint_sha256": sha256_file(
args.base_checkpoint.expanduser().resolve()
),
"train_sessions": train_names,
"eval_sessions": eval_names,
"training_config": {
"epochs": args.epochs,
"batch_size": args.batch_size,
"window_size": args.window_size,
"stride": args.stride,
"lr": args.lr,
"weight_decay": args.weight_decay,
"seed": args.seed,
"amp": args.amp,
"omni_layer_fusion": args.omni_layer_fusion,
"expected_omni_mode": args.expected_omni_mode,
"max_train_examples": args.max_train_examples,
},
"initialization_max_abs_logit_difference": initialization_difference,
"history": history,
}
torch.save(checkpoint_payload, checkpoint_path)
print(f"Saved checkpoint: {checkpoint_path}")
fused_predictions: dict[str, np.ndarray] = {}
base_predictions: dict[str, np.ndarray] = {}
valid: dict[str, np.ndarray] = {}
targets: dict[str, np.ndarray] = {}
reference_audit: dict[str, Any] = {}
for session in eval_sessions:
fused_raw, base_raw = predict_session(
model,
session,
device,
chunk_size=args.eval_chunk_size,
amp=args.amp,
show_progress=not args.no_progress,
)
fused_aligned, session_valid = align_source_predictions(
fused_raw, session.num_steps
)
base_aligned, base_valid = align_source_predictions(base_raw, session.num_steps)
if not np.array_equal(session_valid, base_valid):
raise RuntimeError("Fused/base causal-valid masks unexpectedly differ")
np.save(output_dir / "raw_eval" / f"{session.name}.npy", fused_raw)
np.save(output_dir / "pred" / f"{session.name}.npy", fused_aligned)
np.save(output_dir / "valid" / f"{session.name}.npy", session_valid)
np.save(
output_dir / "frozen_egospeak_raw_eval" / f"{session.name}.npy",
base_raw,
)
np.save(
output_dir / "frozen_egospeak_pred" / f"{session.name}.npy",
base_aligned,
)
fused_predictions[session.name] = fused_aligned
base_predictions[session.name] = base_aligned
valid[session.name] = session_valid
targets[session.name] = np.asarray(session.targets, dtype=np.float32)
if args.reference_prediction_root is not None:
reference_path = (
args.reference_prediction_root.expanduser().resolve()
/ f"{session.name}.npy"
)
if not reference_path.is_file():
raise FileNotFoundError(reference_path)
reference = np.load(reference_path, allow_pickle=False)
if reference.shape != base_raw.shape:
raise ValueError(
f"{session.name}: reference shape {reference.shape} != "
f"{base_raw.shape}"
)
difference = np.abs(reference.astype(np.float32) - base_raw)
maximum = float(difference.max())
reference_audit[session.name] = {
"path": str(reference_path),
"max_abs_difference": maximum,
"mean_abs_difference": float(difference.mean()),
}
print(
f"{session.name}: frozen-base/reference max abs "
f"difference={maximum:.8g}"
)
if maximum > args.reference_tolerance:
raise RuntimeError(
f"{session.name}: frozen MiniROADA did not reproduce the "
f"reference within {args.reference_tolerance}: {maximum}"
)
fused_metrics = evaluate_predictions(fused_predictions, valid, targets)
base_metrics = evaluate_predictions(base_predictions, valid, targets)
metrics = {
**fused_metrics,
"format": "egospeak-omni-zero-residual-eval-v1",
"classes": list(CLASS_NAMES),
"sessions": eval_names,
"prediction_layout": "[target_time, class, future_horizon]",
"raw_prediction_layout": "[source_time, future_horizon, class]",
"checkpoint": str(checkpoint_path),
"train_sessions": train_names,
"frozen_egospeak_baseline": base_metrics,
"reference_prediction_audit": reference_audit,
"initialization_max_abs_logit_difference": initialization_difference,
"omni_sources": {
item.name: item.omni_metadata for item in (*train_sessions, *eval_sessions)
},
}
metrics_path = output_dir / "metrics.json"
metrics_path.write_text(json.dumps(metrics, indent=2) + "\n")
print_metrics("fused", fused_metrics)
print_metrics("frozen EgoSpeak", base_metrics)
print(f"\nSaved metrics: {metrics_path}")
print(f"Fused evaluator input: {output_dir}")
if __name__ == "__main__":
main()