557 lines
21 KiB
Python
557 lines
21 KiB
Python
#!/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()
|