Add EgoSpeak dense event proxy evaluation

This commit is contained in:
Xin Wang
2026-09-15 13:35:35 +08:00
parent b3dc02b293
commit a8a09d8fc5
8 changed files with 826 additions and 24 deletions
+4
View File
@@ -47,6 +47,10 @@ result.
- Timing-derived wearer floor-state and event diagnostics.
- Human-annotated fixed-horizon entry/INT evaluator with point positives,
region-level hard negatives and training-side threshold calibration.
- A separate EgoSpeak-compatible activity-derived event-proxy evaluator that
pools the 0.6--2.0-s wearer trajectory over equal-width 0.6-s causal decision
regions. It preserves one score per annotated opportunity and never treats
the longer annotation evidence interval as a scoring window.
### Measured screening result
+2 -1
View File
@@ -34,7 +34,8 @@ tests.
| [`easycom_competing_risk_floor_evaluation.md`](easycom_competing_risk_floor_evaluation.md) | Running and auditing the competing-risk entry/yield evaluator with training-side threshold calibration |
| [`easycom_floor_event_diagnostics.md`](easycom_floor_event_diagnostics.md) | Separating boundary observability, event-objective mismatch and addressee information with remote commands |
| [`easycom_event_review_tool.md`](easycom_event_review_tool.md) | Reviewing automatically mined EasyCom floor-event candidates against local video and transcripts |
| [`easycom_annotated_floor_evaluation.md`](easycom_annotated_floor_evaluation.md) | Scoring human-confirmed point events and hard-negative opportunity regions at fixed EgoSpeak horizons |
| [`easycom_egospeak_event_proxy.md`](easycom_egospeak_event_proxy.md) | Scoring the EgoSpeak wearer trajectory over equal-width causal decision regions as an activity-derived ENTRY/INT proxy |
| [`easycom_annotated_floor_evaluation.md`](easycom_annotated_floor_evaluation.md) | Reproducing the legacy asymmetric point-positive/region-negative fixed-horizon diagnostic |
## Design history and implementation options
+7 -1
View File
@@ -1,4 +1,10 @@
# EasyCom human-annotated fixed-horizon floor-event evaluation
# Legacy EasyCom fixed-horizon floor-event diagnostic
> **Status:** retained for reproducibility, not the current manuscript event
> protocol. Its one-bin positives and variable-duration hard-negative regions
> are asymmetric. Use the equal-width activity-derived protocol in
> [`easycom_egospeak_event_proxy.md`](easycom_egospeak_event_proxy.md) for the
> current EgoSpeak baseline.
This protocol evaluates saved EgoSpeak-layout dense predictions against
human-confirmed `WEARER_ENTRY`, `INT_YIELD`, and hard-negative opportunities.
+48
View File
@@ -0,0 +1,48 @@
# EgoSpeak dense-trajectory event proxy
`evaluate_egospeak_event_proxy.py` provides a deliberately limited bridge
between EgoSpeak-compatible dense future-activity predictions and reviewed
wearer ENTRY/INT opportunities. It does **not** claim that wearer voice
activity is a semantic floor-event detector.
## Score
For every positive event, the default decision region contains the three 5-Hz
source rows immediately before its corrected behavior anchor. An independently
mined hard negative uses the first three rows of its labeled opportunity. A raw
wearer onset/offset relabeled as a hard negative uses the same pre-boundary
region as a positive. Regions shorter than 0.6 seconds are excluded and
counted; longer annotation evidence is not scored.
At each source row, the script recovers the target-aligned wearer trajectory
at 0.6, 0.8, ..., 2.0 seconds. ENTRY uses its mean and INT uses one minus its
mean. Max-pooling over the three decision rows produces one score per reviewed
opportunity. The output must be called an `activity-derived event proxy`.
## Threshold-free provisional run
This reports AP without fitting thresholds on Sessions 1--3:
```bash
python3 evaluate_egospeak_event_proxy.py \
--reviews reports/easycom_entry_int_sessions1_4_v16/easycom_eot_int_provisional_llm_reviews.jsonl \
--run egospeak=results/egospeak_miniroada_floor_eval \
--sessions Session_1 Session_2 Session_3 \
--allow-provisional-llm-reviews \
--print-event-examples 5 \
--output results/easycom_egospeak_event_proxy/sessions1_3_provisional_summary.json
```
The command writes JSON, a compact metrics CSV, and an event CSV containing the
three decision-frame scores and the winning selected-horizon wearer trajectory.
## Recall-matched operating points
`--matched-recalls` on official test sessions additionally requires disjoint
Sessions 4--12 reviews and predictions via `--calibration-reviews`,
`--calibration-sessions`, and optionally `--calibration-run`. Do not use
`--allow-in-sample-thresholds` for reported results.
The default 0.6-second decision window and 0.6--2.0-second trajectory range
remain protocol choices to freeze using training-side data. Report shorter or
longer windows only as predeclared sensitivity analyses.
+2
View File
@@ -57,6 +57,7 @@ class AnnotatedOpportunity:
anchor_seconds: float
region_start_seconds: float
region_end_seconds: float
candidate_source: str = ""
def parse_args() -> argparse.Namespace:
@@ -328,6 +329,7 @@ def load_annotated_opportunities(
anchor_seconds=anchor_seconds,
region_start_seconds=region_start,
region_end_seconds=region_end,
candidate_source=str(candidate.get("candidate_source", "")),
)
)
+556
View File
@@ -0,0 +1,556 @@
#!/usr/bin/env python3
"""Evaluate an activity-derived ENTRY/YIELD proxy from dense forecasts.
This evaluator does not treat wearer voice-activity probability as a semantic
event detector. Instead, it asks whether the multi-horizon wearer trajectory
separates adjudicated ENTRY/YIELD events from matched hard-negative
opportunities.
Every opportunity contributes at most one score. Positive events use a fixed
causal decision region immediately before the corrected behavior anchor.
Independently mined hard negatives use an equal-width region beginning at the
annotated opportunity onset. A raw wearer boundary relabeled as a hard
negative uses the pre-boundary region, just like its positive counterpart.
Long annotation/evidence regions are never used as scoring windows.
At every 5-Hz source row, the evaluator follows the target-aligned prediction
tensor diagonally to recover wearer probabilities from one causal prediction
time across selected future horizons. Their mean is a sustained-activity
proxy for ENTRY; one minus that mean is the corresponding YIELD proxy. The
maximum over the fixed decision region produces one event-level score.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
from pathlib import Path
from typing import Any, Iterable
import numpy as np
from evaluate_annotated_floor_events import (
AnnotatedOpportunity,
FAMILIES,
Family,
load_annotated_opportunities,
load_session_predictions,
)
from evaluate_floor_state_changes import (
CLASS_NAMES,
NUM_HORIZONS,
OFFICIAL_TEST_SESSIONS,
OFFICIAL_TRAIN_SESSIONS,
STEP_SECONDS,
WEARER_INDEX,
_operating_points,
_safe_ap,
parse_runs,
validate_recalls,
)
RAW_WEARER_BOUNDARY_SOURCES = frozenset({"WEARER_ONSET", "WEARER_OFFSET"})
DEFAULT_PROXY_HORIZONS = tuple(round(value * STEP_SECONDS, 10) for value in range(3, 11))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--reviews", type=Path, action="append", required=True)
parser.add_argument(
"--run",
action="append",
required=True,
metavar="NAME=RESULT_DIR",
help="Inference directory containing pred/ and valid/. Repeat per method.",
)
parser.add_argument("--sessions", nargs="+", default=list(OFFICIAL_TEST_SESSIONS))
parser.add_argument("--calibration-reviews", type=Path, action="append")
parser.add_argument("--calibration-run", action="append", metavar="NAME=RESULT_DIR")
parser.add_argument("--calibration-sessions", nargs="+")
parser.add_argument(
"--matched-recalls",
nargs="*",
type=float,
default=(),
help=(
"Optional recall operating points. Official Sessions 1--3 require "
"disjoint training-side calibration when this option is non-empty."
),
)
parser.add_argument(
"--allow-in-sample-thresholds",
action="store_true",
help="Exploratory only: select requested operating points on evaluated events.",
)
parser.add_argument("--decision-window-seconds", type=float, default=0.6)
parser.add_argument(
"--proxy-horizons-seconds",
nargs="+",
type=float,
default=DEFAULT_PROXY_HORIZONS,
metavar="SECONDS",
help="Future horizons averaged into the sustained-activity proxy.",
)
parser.add_argument(
"--output",
type=Path,
default=Path("results/easycom_egospeak_event_proxy/summary.json"),
)
parser.add_argument("--csv-output", type=Path)
parser.add_argument("--event-csv-output", type=Path)
parser.add_argument("--print-event-examples", type=int, default=0, metavar="N")
parser.add_argument("--allow-provisional-llm-reviews", action="store_true")
return parser.parse_args()
def _grid_steps(seconds: float, *, field: str) -> int:
if not math.isfinite(seconds) or seconds <= 0:
raise ValueError(f"{field} must be finite and positive")
steps = int(round(seconds / STEP_SECONDS))
if not math.isclose(seconds, steps * STEP_SECONDS, abs_tol=1e-7):
raise ValueError(f"{field} must lie on the {STEP_SECONDS:g}-s grid")
return steps
def horizon_indices(values: Iterable[float]) -> list[int]:
result: list[int] = []
for value in values:
steps = _grid_steps(float(value), field="proxy horizon")
if not 1 <= steps <= NUM_HORIZONS:
raise ValueError(
f"proxy horizon must be between {STEP_SECONDS:g} and "
f"{NUM_HORIZONS * STEP_SECONDS:g} seconds"
)
result.append(steps - 1)
if len(set(result)) != len(result):
raise ValueError("proxy horizons must be unique")
return sorted(result)
def _anchor_row(opportunity: AnnotatedOpportunity) -> int:
return int(math.floor(opportunity.anchor_seconds / STEP_SECONDS + 1e-7))
def decision_source_rows(
opportunity: AnnotatedOpportunity,
*,
window_rows: int,
) -> tuple[int, int, str] | None:
"""Return an equal-width half-open source-row region for one opportunity."""
raw_boundary = opportunity.candidate_source in RAW_WEARER_BOUNDARY_SOURCES
if not opportunity.candidate_source:
# Backward-compatible fallback for older reviewed JSONL without source.
raw_boundary = (
opportunity.region_start_seconds + 1e-7 < opportunity.anchor_seconds
< opportunity.region_end_seconds - 1e-7
)
if opportunity.positive or raw_boundary:
end = _anchor_row(opportunity)
start = end - window_rows
basis = "pre_behavior_anchor"
else:
start = opportunity.start_row
end = start + window_rows
basis = "hard_negative_opportunity_onset"
if end > opportunity.end_row_exclusive:
return None
if start < 0 or end <= start:
return None
return start, end, basis
def _source_trajectory(
prediction: np.ndarray,
valid: np.ndarray,
source_row: int,
selected_horizons: list[int],
) -> np.ndarray | None:
values: list[float] = []
for horizon_index in selected_horizons:
target_row = source_row + horizon_index + 1
if target_row < 0 or target_row >= len(prediction):
return None
if not valid[target_row, horizon_index]:
return None
values.append(float(prediction[target_row, WEARER_INDEX, horizon_index]))
return np.asarray(values, dtype=np.float64)
def score_activity_proxy(
opportunity: AnnotatedOpportunity,
arrays: dict[str, tuple[np.ndarray, np.ndarray]],
*,
window_rows: int,
selected_horizons: list[int],
) -> tuple[float, dict[str, Any]] | None:
region = decision_source_rows(opportunity, window_rows=window_rows)
if region is None:
return None
source_start, source_end, basis = region
prediction, valid = arrays[opportunity.session]
frame_scores: list[float] = []
trajectories: list[list[float]] = []
for source_row in range(source_start, source_end):
trajectory = _source_trajectory(
prediction, valid, source_row, selected_horizons
)
if trajectory is None:
return None
sustained_activity = float(np.mean(trajectory))
score = sustained_activity if opportunity.family == "entry" else 1.0 - sustained_activity
frame_scores.append(score)
trajectories.append(trajectory.tolist())
best_index = int(np.argmax(frame_scores))
return float(frame_scores[best_index]), {
"decision_basis": basis,
"source_start_row": source_start,
"source_end_row_exclusive": source_end,
"best_source_row": source_start + best_index,
"frame_scores": frame_scores,
"best_wearer_trajectory": trajectories[best_index],
}
def evaluate_family(
opportunities: list[AnnotatedOpportunity],
arrays: dict[str, tuple[np.ndarray, np.ndarray]],
family: Family,
recalls: list[float],
*,
window_rows: int,
selected_horizons: list[int],
fixed_thresholds: dict[float, float | None] | None = None,
threshold_source: str = "not_requested",
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
rows: list[dict[str, Any]] = []
invalid = 0
for opportunity in opportunities:
if opportunity.family != family:
continue
scored = score_activity_proxy(
opportunity,
arrays,
window_rows=window_rows,
selected_horizons=selected_horizons,
)
if scored is None:
invalid += 1
continue
score, details = scored
rows.append(
{
"uid": opportunity.uid,
"session": opportunity.session,
"family": family,
"positive": opportunity.positive,
"subtype": opportunity.subtype,
"candidate_source": opportunity.candidate_source,
"score": score,
"anchor_seconds": opportunity.anchor_seconds,
"evidence_region_start_seconds": opportunity.region_start_seconds,
"evidence_region_end_seconds": opportunity.region_end_seconds,
**details,
}
)
scores = np.asarray([row["score"] for row in rows], dtype=np.float64)
positive = np.asarray([row["positive"] for row in rows], dtype=bool)
negative = ~positive
labels = positive.astype(np.int8)
subtype_names = sorted({str(row["subtype"]) for row in rows if not row["positive"]})
extra_groups = {
f"fpr_{name.lower()}": np.asarray(
[not row["positive"] and row["subtype"] == name for row in rows],
dtype=bool,
)
for name in subtype_names
}
operating_points: list[dict[str, Any]] = []
if recalls:
operating_points = _operating_points(
scores,
positive,
negative,
recalls,
extra_groups=extra_groups,
fixed_thresholds=fixed_thresholds,
threshold_source=threshold_source,
)
metric = {
"family": family,
"num_positive": int(positive.sum()),
"num_hard_negative": int(negative.sum()),
"num_invalid_or_short": invalid,
"average_precision": _safe_ap(labels, scores),
"operating_points": operating_points,
}
return metric, rows
def _thresholds(metric: dict[str, Any]) -> dict[float, float | None]:
return {
float(item["requested_positive_recall"]): item["threshold"]
for item in metric["operating_points"]
}
def _validate_calibration(
args: argparse.Namespace,
runs: list[tuple[str, Path]],
recalls: list[float],
) -> tuple[list[tuple[str, Path]], bool]:
supplied = [
bool(args.calibration_reviews),
bool(args.calibration_sessions),
]
if any(supplied) and not all(supplied):
raise ValueError(
"--calibration-reviews and --calibration-sessions must be supplied together"
)
has_calibration = all(supplied)
if args.calibration_run and not has_calibration:
raise ValueError(
"--calibration-run requires --calibration-reviews and "
"--calibration-sessions"
)
if has_calibration:
unknown = sorted(set(args.calibration_sessions) - set(OFFICIAL_TRAIN_SESSIONS))
if unknown:
raise ValueError(f"Calibration must use EasyCom Sessions 4--12, got {unknown}")
overlap = sorted(set(args.sessions) & set(args.calibration_sessions))
if overlap:
raise ValueError(f"Evaluation and calibration sessions overlap: {overlap}")
official_test = bool(set(args.sessions) & set(OFFICIAL_TEST_SESSIONS))
if recalls and official_test and not has_calibration and not args.allow_in_sample_thresholds:
raise ValueError(
"Official Sessions 1--3 require training-side calibration for "
"--matched-recalls; omit --matched-recalls for threshold-free AP"
)
if args.calibration_run:
calibration_runs = parse_runs(args.calibration_run)
if [name for name, _ in calibration_runs] != [name for name, _ in runs]:
raise ValueError("--calibration-run names/order must match --run")
else:
calibration_runs = runs
return calibration_runs, has_calibration
def _write_event_csv(path: Path, rows: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fieldnames = [
"run", "uid", "session", "family", "positive", "subtype",
"candidate_source", "score", "anchor_seconds", "decision_basis",
"source_start_row", "source_end_row_exclusive", "best_source_row",
"evidence_region_start_seconds", "evidence_region_end_seconds",
"frame_scores", "best_wearer_trajectory",
]
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
serialized = dict(row)
serialized["frame_scores"] = json.dumps(row["frame_scores"])
serialized["best_wearer_trajectory"] = json.dumps(
row["best_wearer_trajectory"]
)
writer.writerow(serialized)
def _write_metric_csv(path: Path, results: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fieldnames = [
"run", "family", "num_positive", "num_hard_negative",
"num_invalid_or_short", "average_precision", "requested_recall",
"threshold", "threshold_source", "achieved_recall",
"false_positive_rate", "false_positive_count",
]
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
for run in results:
for metric in run["metrics"]:
points = metric["operating_points"] or [None]
for point in points:
writer.writerow(
{
"run": run["name"],
"family": metric["family"],
"num_positive": metric["num_positive"],
"num_hard_negative": metric["num_hard_negative"],
"num_invalid_or_short": metric["num_invalid_or_short"],
"average_precision": metric["average_precision"],
"requested_recall": (
"" if point is None else point["requested_positive_recall"]
),
"threshold": "" if point is None else point["threshold"],
"threshold_source": (
"" if point is None else point["threshold_source"]
),
"achieved_recall": (
"" if point is None else point["achieved_positive_recall"]
),
"false_positive_rate": (
"" if point is None else point["negative_false_positive_rate"]
),
"false_positive_count": (
"" if point is None else point["negative_false_positive_count"]
),
}
)
def _print_examples(rows: list[dict[str, Any]], limit: int) -> None:
if limit <= 0:
return
print("\nActivity-derived proxy examples (one score per opportunity)")
for family in FAMILIES:
print(f"{family}:")
shown = 0
for row in rows:
if row["family"] != family:
continue
label = "POS" if row["positive"] else "HN"
print(
f" {label} {row['session']} {row['uid']} "
f"source_rows=[{row['source_start_row']},{row['source_end_row_exclusive']}) "
f"best_source={row['best_source_row']} "
f"evidence={row['evidence_region_start_seconds']:.2f}--"
f"{row['evidence_region_end_seconds']:.2f}s "
f"score={row['score']:.4f} subtype={row['subtype']}"
)
shown += 1
if shown >= limit:
break
def main() -> None:
args = parse_args()
window_rows = _grid_steps(args.decision_window_seconds, field="decision window")
selected_horizons = horizon_indices(args.proxy_horizons_seconds)
recalls = validate_recalls(args.matched_recalls) if args.matched_recalls else []
runs = parse_runs(args.run)
calibration_runs, has_calibration = _validate_calibration(args, runs, recalls)
opportunities, review_diagnostics = load_annotated_opportunities(
args.reviews,
args.sessions,
allow_provisional_llm=args.allow_provisional_llm_reviews,
)
if not opportunities:
raise ValueError("No scoring-eligible reviewed opportunities")
calibration_opportunities = None
calibration_diagnostics = None
if has_calibration:
calibration_opportunities, calibration_diagnostics = load_annotated_opportunities(
args.calibration_reviews,
args.calibration_sessions,
allow_provisional_llm=args.allow_provisional_llm_reviews,
)
if not calibration_opportunities:
raise ValueError("No scoring-eligible calibration opportunities")
results: list[dict[str, Any]] = []
event_rows: list[dict[str, Any]] = []
first_rows: list[dict[str, Any]] = []
for run_index, ((name, run_dir), (_, calibration_dir)) in enumerate(
zip(runs, calibration_runs)
):
arrays = load_session_predictions(run_dir, args.sessions)
calibration_arrays = (
load_session_predictions(calibration_dir, args.calibration_sessions)
if calibration_opportunities is not None
else None
)
metrics: list[dict[str, Any]] = []
run_rows: list[dict[str, Any]] = []
for family in FAMILIES:
fixed_thresholds = None
threshold_source = "evaluated_events_exploratory"
if calibration_opportunities is not None and calibration_arrays is not None:
calibration_metric, _ = evaluate_family(
calibration_opportunities,
calibration_arrays,
family,
recalls,
window_rows=window_rows,
selected_horizons=selected_horizons,
threshold_source="training_side_calibration",
)
fixed_thresholds = _thresholds(calibration_metric)
threshold_source = "training_side_calibration"
metric, rows = evaluate_family(
opportunities,
arrays,
family,
recalls,
window_rows=window_rows,
selected_horizons=selected_horizons,
fixed_thresholds=fixed_thresholds,
threshold_source=threshold_source,
)
metrics.append(metric)
run_rows.extend({"run": name, **row} for row in rows)
if run_index == 0:
first_rows.extend(rows)
event_rows.extend(run_rows)
results.append({"name": name, "result_dir": str(run_dir), "metrics": metrics})
provisional = bool(
review_diagnostics["num_provisional_llm"]
+ (0 if calibration_diagnostics is None else calibration_diagnostics["num_provisional_llm"])
)
payload = {
"schema_version": 1,
"protocol": "egospeak-wearer-trajectory-event-proxy-v1",
"semantic_scope": (
"Activity-derived diagnostic, not a semantic event detector. "
"Provisional LLM auto-accepted labels are present."
if provisional
else "Activity-derived diagnostic on human-reviewed opportunities; "
"the wearer trajectory is not a semantic event detector."
),
"provisional_llm": provisional,
"prediction_layout": "[target_time, class, future_horizon]",
"class_names": list(CLASS_NAMES),
"sessions": list(args.sessions),
"calibration_sessions": args.calibration_sessions,
"decision_window_seconds": args.decision_window_seconds,
"decision_window_rows": window_rows,
"proxy_horizons_seconds": [
(index + 1) * STEP_SECONDS for index in selected_horizons
],
"source_frame_proxy": (
"entry=mean wearer probability; int=1-mean wearer probability; "
"mean over selected future horizons"
),
"opportunity_pooling": "maximum over fixed-width decision source rows",
"review_diagnostics": review_diagnostics,
"calibration_review_diagnostics": calibration_diagnostics,
"matched_recalls": recalls,
"runs": results,
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
event_path = args.event_csv_output or args.output.with_name(
args.output.stem + "_events.csv"
)
metric_path = args.csv_output or args.output.with_name(
args.output.stem + "_metrics.csv"
)
_write_metric_csv(metric_path, results)
_write_event_csv(event_path, event_rows)
_print_examples(first_rows, args.print_event_examples)
print(f"Saved {args.output}")
print(f"Saved {metric_path}")
print(f"Saved {event_path}")
if __name__ == "__main__":
main()
+37 -22
View File
@@ -328,32 +328,47 @@ configuration, trainable parameter counts, and inference latency.}
We first retain dense future-activity evaluation for direct comparison with
EgoSpeak, reporting AP at the 0.2--2.0~s horizons and their mean mAP.
Behavioral evaluation uses fixed-horizon event classification. For both
wearer-entry and INT-yield, we align the dense forecast to each adjudicated
item at each of the ten nominal horizons. A positive remains a single corrected
5-Hz behavior-anchor bin: its entry score is wearer activity probability and
its INT-yield score is one minus wearer activity probability. We do not expand
the positive timestamp into a tolerance window. A hard-negative opportunity is
one sample whose score is the maximum erroneous-action probability over its
labeled half-open region at the same fixed horizon. Thus, each positive event
or hard-negative opportunity contributes exactly one score per horizon.
For dense-only models, behavioral evaluation begins with an explicitly
activity-derived event proxy rather than treating wearer voice activity as a
semantic event detector. Following the prediction-region principle used by
VAP~\cite{ekstedt2022vap}, every adjudicated item contributes one score from a
fixed 0.6-s (three-frame) causal decision region. A positive uses the three
source frames immediately preceding its corrected behavior anchor. An
independently mined hard negative uses the first three source frames after its
opportunity begins; a raw wearer boundary relabeled as a hard negative instead
uses the same pre-boundary region as a positive. Items without a complete
three-frame region are excluded and counted. The longer interval supplied to
the annotator is evidence context only and is never a model-scoring window.
At each decision frame, we follow the target-aligned forecast tensor
diagonally to recover the wearer probabilities emitted at that same causal
source time. We average the 0.6--2.0-s horizons to obtain a sustained wearer-
activity score. This mean is the entry proxy, and one minus the mean is the
INT-yield proxy. Max-pooling the three decision-frame scores gives exactly one
score per positive event or hard-negative opportunity. This construction can
capture sustained activity versus a brief vocalization, but it cannot by
itself establish semantic floor intent; in particular, wearer backchannels and
within-turn pauses remain deliberately difficult negatives. A later linear
event probe, if used, is fit only on training-side annotations and compared at
matched capacity across representations, following the separated downstream-
probe logic of MuVAP~\cite{qi2026muvap}.
\textbf{False-positive scoring rule.}
A false positive is a hard-negative opportunity whose region-level score
A false positive is a hard-negative opportunity whose fixed-region proxy score
exceeds the frozen threshold, regardless of whether the wearer subsequently
exhibits an onset or offset. Region max-pooling means that multiple high-scoring
frames still count as one false-positive opportunity. Thus, false-positive
exhibits an onset or offset. Max-pooling means that multiple high-scoring
decision frames still count as one false-positive opportunity. Thus, false-positive
evaluation includes conversational situations in which the correct wearer
behavior is to remain silent or retain the floor.
For every horizon we report event AP and the recall--false-positive trade-off.
The headline behavioral operating point uses the first, nominal 0.2-s forecast
bin at matched recall $R=0.8$; thresholds are selected independently for entry
and INT from training-side out-of-fold predictions. We additionally report the
complete 0.2--2.0-s curves and the other-to-other hard-negative subset to test
We report threshold-free proxy AP for entry and INT. The headline behavioral
operating point additionally reports false-positive rate at matched recall
$R=0.8$; thresholds are selected independently for entry and INT from training-
side out-of-fold predictions. The other-to-other hard-negative subset tests
whether local visual evidence specifically reduces erroneous wearer action
scores during multiparty floor transfers. \todo{Freeze the hard-negative
region endpoints and verify identical event denominators across methods.}
scores during multiparty floor transfers. \todo{Freeze the 0.6-s decision
window and 0.6--2.0-s aggregation range using training-side data, run window
sensitivity analysis, and verify identical event denominators across methods.}
A deployment-style committed-event analysis with one-to-one matching and
signed latency is secondary and is omitted unless a threshold, persistence,
@@ -378,8 +393,8 @@ verify identical targets, causal validity, and evaluation.}
\begin{table}[t]
\caption{Confirmatory EasyCom results. Entry and INT false-positive rates use
the nominal 0.2-s forecast bin at matched recall $R=0.8$; E/I AP averages each
event family over all ten fixed horizons.}
the fixed-region activity-derived proxy at matched recall $R=0.8$; E/I AP is
the corresponding event-balanced proxy AP.}
\label{tab:main}
\centering
\small
@@ -411,7 +426,7 @@ evidence primarily improves \todo{the interpretation supported by the data}.
\textbf{Annotated behavior.}
At matched wearer-entry recall $R=0.8$, the proposed adapter reduces entry FPR
from \todo{$X$} to \todo{$Y$}; at matched INT-yield recall, it changes INT FPR
from \todo{$A$} to \todo{$B$}. Its ten-horizon mean entry/INT event APs are
from \todo{$A$} to \todo{$B$}. Its entry/INT activity-proxy APs are
\todo{$E/I$}. These recall-matched opportunity error rates test whether lower
scores on confusing floor situations are obtained without suppressing
legitimate entry or yield events.
+170
View File
@@ -0,0 +1,170 @@
from __future__ import annotations
import unittest
import numpy as np
from evaluate_annotated_floor_events import AnnotatedOpportunity
from evaluate_egospeak_event_proxy import (
decision_source_rows,
evaluate_family,
horizon_indices,
score_activity_proxy,
)
from evaluate_floor_state_changes import NUM_CLASSES, NUM_HORIZONS
def opportunity(
uid: str,
*,
family: str = "entry",
positive: bool = True,
start: int = 20,
end: int = 21,
anchor: float = 4.0,
region_start: float = 4.0,
region_end: float = 4.0,
source: str = "WEARER_ONSET",
) -> AnnotatedOpportunity:
return AnnotatedOpportunity(
uid=uid,
session="Session_4",
family=family, # type: ignore[arg-type]
positive=positive,
subtype=("WEARER_ENTRY" if family == "entry" else "INT_YIELD")
if positive
else ("ENTRY_OTHER_TO_OTHER" if family == "entry" else "INT_OTHER_BACKCHANNEL"),
start_row=start,
end_row_exclusive=end,
anchor_seconds=anchor,
region_start_seconds=region_start,
region_end_seconds=region_end,
candidate_source=source,
)
def arrays(length: int = 50) -> tuple[np.ndarray, np.ndarray]:
return (
np.zeros((length, NUM_CLASSES, NUM_HORIZONS), dtype=np.float32),
np.ones((length, NUM_HORIZONS), dtype=bool),
)
class EgoSpeakEventProxyTests(unittest.TestCase):
def test_positive_uses_three_pre_anchor_source_rows(self) -> None:
item = opportunity("positive", anchor=4.0)
self.assertEqual(
decision_source_rows(item, window_rows=3),
(17, 20, "pre_behavior_anchor"),
)
def test_independent_hard_negative_uses_only_equal_width_region_prefix(self) -> None:
item = opportunity(
"negative",
positive=False,
start=10,
end=40,
anchor=2.0,
region_start=2.0,
region_end=8.0,
source="OTHER_TO_OTHER",
)
self.assertEqual(
decision_source_rows(item, window_rows=3),
(10, 13, "hard_negative_opportunity_onset"),
)
def test_short_hard_negative_is_not_silently_padded(self) -> None:
item = opportunity(
"short",
positive=False,
start=10,
end=12,
source="OTHER_TO_OTHER",
)
self.assertIsNone(decision_source_rows(item, window_rows=3))
def test_relabelled_raw_boundary_uses_pre_anchor_rows(self) -> None:
item = opportunity(
"backchannel",
positive=False,
start=5,
end=35,
anchor=4.0,
region_start=1.0,
region_end=7.0,
source="WEARER_ONSET",
)
self.assertEqual(
decision_source_rows(item, window_rows=3),
(17, 20, "pre_behavior_anchor"),
)
def test_trajectory_follows_target_aligned_diagonal(self) -> None:
item = opportunity("positive", anchor=4.0)
prediction, valid = arrays()
selected = horizon_indices([0.6, 0.8, 1.0])
# Best decision row is 18. Target rows are source+horizon_index+1.
prediction[21, 1, 2] = 0.6
prediction[22, 1, 3] = 0.9
prediction[23, 1, 4] = 0.3
scored = score_activity_proxy(
item,
{"Session_4": (prediction, valid)},
window_rows=3,
selected_horizons=selected,
)
self.assertIsNotNone(scored)
score, details = scored # type: ignore[misc]
self.assertAlmostEqual(score, 0.6)
self.assertEqual(details["best_source_row"], 18)
self.assertEqual(len(details["best_wearer_trajectory"]), 3)
def test_yield_inverts_sustained_wearer_activity(self) -> None:
item = opportunity("yield", family="int", anchor=4.0, source="WEARER_OFFSET")
prediction, valid = arrays()
selected = horizon_indices([0.6, 0.8])
for source_row in range(17, 20):
prediction[source_row + 3, 1, 2] = 0.2
prediction[source_row + 4, 1, 3] = 0.2
scored = score_activity_proxy(
item,
{"Session_4": (prediction, valid)},
window_rows=3,
selected_horizons=selected,
)
self.assertIsNotNone(scored)
self.assertAlmostEqual(scored[0], 0.8) # type: ignore[index]
def test_each_opportunity_contributes_one_metric_row(self) -> None:
positive = opportunity("p", anchor=4.0)
negative = opportunity(
"n",
positive=False,
start=25,
end=30,
anchor=5.0,
region_start=5.0,
region_end=6.0,
source="OTHER_TO_OTHER",
)
prediction, valid = arrays()
selected = horizon_indices([0.6])
for source_row in range(17, 20):
prediction[source_row + 3, 1, 2] = 0.9
metric, rows = evaluate_family(
[positive, negative],
{"Session_4": (prediction, valid)},
"entry",
[],
window_rows=3,
selected_horizons=selected,
)
self.assertEqual(len(rows), 2)
self.assertEqual(metric["num_positive"], 1)
self.assertEqual(metric["num_hard_negative"], 1)
self.assertAlmostEqual(metric["average_precision"], 1.0)
if __name__ == "__main__":
unittest.main()