Add overlap-aware turn prediction objectives

This commit is contained in:
Xin Wang
2026-08-26 15:03:56 +08:00
parent f45ea9cbc6
commit f35b9108ac
7 changed files with 391 additions and 25 deletions
+193 -4
View File
@@ -30,9 +30,22 @@ CLASS_NAMES = (
"camera_wearer_speaking",
"other_people_speaking",
)
FOUR_STATE_NAMES = (
"silence",
"wearer_only",
"others_only",
"overlap",
)
STEP_SECONDS = 0.2
NUM_HORIZONS = 10
NUM_CLASSES = 3
NUM_FOUR_STATES = len(FOUR_STATE_NAMES)
LOSS_NAMES = (
"lstr-mce",
"miniroad-normalized",
"independent-bce",
"four-state-ce",
)
DATASET_NAMES = ("easycom", "ego4d")
LAYER_FUSION_MODES = (
"single",
@@ -44,7 +57,7 @@ DatasetName = Literal["easycom", "ego4d"]
class TurnPredictionHead(nn.Module):
"""Map one Omni state to EgoSpeak-compatible ``[10, 3]`` logits."""
"""Map one Omni state to configurable ``[horizon, output]`` logits."""
def __init__(
self,
@@ -150,18 +163,76 @@ class TurnPredictionHead(nn.Module):
return self.layer_logits.softmax(dim=0)
def _validate_loss_inputs(logits: torch.Tensor, targets: torch.Tensor) -> None:
def _validate_loss_inputs(
logits: torch.Tensor,
targets: torch.Tensor,
*,
num_output_classes: int = NUM_CLASSES,
) -> None:
if logits.shape != targets.shape:
raise ValueError(
f"logits/targets shape mismatch: {tuple(logits.shape)} vs "
f"{tuple(targets.shape)}"
)
if logits.shape[-1] != NUM_CLASSES:
if logits.shape[-1] != num_output_classes:
raise ValueError(
f"Expected {NUM_CLASSES} classes, got {logits.shape[-1]}"
f"Expected {num_output_classes} classes, got {logits.shape[-1]}"
)
def _validated_binary_activity_targets(targets: torch.Tensor) -> torch.Tensor:
"""Validate the four legal EasyCom/EgoSpeak three-label activity rows."""
if targets.ndim < 1 or targets.shape[-1] != NUM_CLASSES:
raise ValueError(
f"Expected activity targets [...,{NUM_CLASSES}], got "
f"{tuple(targets.shape)}"
)
if not torch.isfinite(targets).all():
raise ValueError("Activity targets contain NaN or Inf")
rounded = targets.round()
if not torch.allclose(targets, rounded, atol=1e-6, rtol=0.0):
raise ValueError("Activity targets must contain only binary 0/1 values")
expected_background = (rounded[..., 1:].sum(dim=-1) == 0).to(rounded.dtype)
if not torch.equal(rounded[..., 0], expected_background):
raise ValueError(
"Activity targets must be one of [1,0,0], [0,1,0], "
"[0,0,1], or [0,1,1]"
)
return rounded
def activity_targets_to_four_states(targets: torch.Tensor) -> torch.LongTensor:
"""Map three-label activity targets to silence/wearer/others/overlap."""
targets = _validated_binary_activity_targets(targets)
return (targets[..., 1] + 2 * targets[..., 2]).long()
def output_classes_for_loss(loss_name: str) -> int:
"""Return the number of head logits required by one training objective."""
if loss_name not in LOSS_NAMES:
raise ValueError(f"Unknown loss {loss_name!r}; expected one of {LOSS_NAMES}")
return NUM_FOUR_STATES if loss_name == "four-state-ce" else NUM_CLASSES
def output_space_for_loss(loss_name: str) -> str:
"""Return a stable checkpoint description of the head output space."""
output_classes_for_loss(loss_name)
if loss_name == "four-state-ce":
return "four-state-softmax"
if loss_name == "independent-bce":
return "three-label-sigmoid"
return "three-class-softmax"
def head_output_names_for_loss(loss_name: str) -> tuple[str, ...]:
output_classes_for_loss(loss_name)
return FOUR_STATE_NAMES if loss_name == "four-state-ce" else CLASS_NAMES
def egospeak_multihorizon_loss(
logits: torch.Tensor,
targets: torch.Tensor,
@@ -211,6 +282,99 @@ def miniroad_multihorizon_loss(
raise ValueError(f"Unknown reduction: {reduction}")
def independent_bce_multihorizon_loss(
logits: torch.Tensor,
targets: torch.Tensor,
*,
reduction: Literal["mean", "sum", "none"] = "mean",
) -> torch.Tensor:
"""Independent sigmoid/BCE loss for the three marginal activity labels."""
_validate_loss_inputs(logits, targets)
targets = targets.to(
device=logits.device,
dtype=logits.dtype,
)
element_loss = nn.functional.binary_cross_entropy_with_logits(
logits,
targets,
reduction="none",
)
if reduction == "mean":
return element_loss.mean()
if reduction == "sum":
return element_loss.sum()
if reduction == "none":
return element_loss
raise ValueError(f"Unknown reduction: {reduction}")
def four_state_multihorizon_loss(
logits: torch.Tensor,
targets: torch.Tensor,
*,
reduction: Literal["mean", "sum", "none"] = "mean",
) -> torch.Tensor:
"""Four-state CE for silence, wearer-only, others-only, and overlap."""
if logits.ndim < 1 or logits.shape[-1] != NUM_FOUR_STATES:
raise ValueError(
f"Expected four-state logits [...,{NUM_FOUR_STATES}], got "
f"{tuple(logits.shape)}"
)
if logits.shape[:-1] != targets.shape[:-1] or targets.shape[-1] != NUM_CLASSES:
raise ValueError(
"Four-state logits/activity target shape mismatch: "
f"{tuple(logits.shape)} vs {tuple(targets.shape)}"
)
state_targets = (targets[..., 1] + 2 * targets[..., 2]).long().to(
logits.device
)
loss = nn.functional.cross_entropy(
logits.reshape(-1, NUM_FOUR_STATES),
state_targets.reshape(-1),
reduction="none",
).reshape(logits.shape[:-1])
if reduction == "mean":
return loss.mean()
if reduction == "sum":
return loss.sum()
if reduction == "none":
return loss
raise ValueError(f"Unknown reduction: {reduction}")
def marginal_probabilities_from_logits(
logits: torch.Tensor,
loss_name: str,
) -> torch.Tensor:
"""Convert any supported head output to three EgoSpeak marginals.
Four-state marginals deliberately are not renormalized: wearer and others
may both be near one when the overlap state is likely.
"""
expected_classes = output_classes_for_loss(loss_name)
if logits.ndim < 1 or logits.shape[-1] != expected_classes:
raise ValueError(
f"Loss {loss_name!r} expects {expected_classes} logits, got "
f"{tuple(logits.shape)}"
)
if loss_name == "independent-bce":
return logits.sigmoid()
probabilities = logits.softmax(dim=-1)
if loss_name != "four-state-ce":
return probabilities
return torch.stack(
(
probabilities[..., 0],
probabilities[..., 1] + probabilities[..., 3],
probabilities[..., 2] + probabilities[..., 3],
),
dim=-1,
)
def multihorizon_cross_entropy(
logits: torch.Tensor,
targets: torch.Tensor,
@@ -380,6 +544,7 @@ def load_session_data(
f"{target_path}"
)
targets = torch.from_numpy(np.asarray(target_array, dtype=np.float32))
_validated_binary_activity_targets(targets)
if len(label_indices) and int(label_indices[-1]) >= len(targets):
raise ValueError(
f"Last label index {int(label_indices[-1])} exceeds target length "
@@ -455,6 +620,30 @@ def checkpoint_head_config(payload: dict[str, Any]) -> dict[str, Any]:
return config
def checkpoint_loss_name(payload: dict[str, Any]) -> str:
"""Read the objective from a checkpoint, defaulting legacy files to MCE."""
training_config = payload.get("training_config", {})
if not isinstance(training_config, dict):
raise ValueError("Checkpoint training_config must be a mapping")
loss_name = str(training_config.get("loss", "lstr-mce"))
expected_classes = output_classes_for_loss(loss_name)
actual_classes = int(checkpoint_head_config(payload)["num_classes"])
if actual_classes != expected_classes:
raise ValueError(
f"Checkpoint loss {loss_name!r} requires {expected_classes} head "
f"outputs, but head_config has {actual_classes}"
)
recorded_space = payload.get("output_space")
expected_space = output_space_for_loss(loss_name)
if recorded_space is not None and recorded_space != expected_space:
raise ValueError(
f"Checkpoint output_space is {recorded_space!r}, expected "
f"{expected_space!r} for loss {loss_name!r}"
)
return loss_name
def validate_disjoint_sessions(
first: Sequence[str],
second: Sequence[str],