Add annotated fixed-horizon floor evaluation

This commit is contained in:
Xin Wang
2026-09-15 02:27:48 +08:00
parent 3a127a3f9f
commit 2ab56f48d7
7 changed files with 1099 additions and 38 deletions

View File

@@ -0,0 +1,668 @@
#!/usr/bin/env python3
"""Evaluate human-adjudicated wearer entry and INT-yield opportunities.
The forecasting model emits dense EgoSpeak-compatible probabilities rather
than event detections. At each nominal horizon, a confirmed positive event is
therefore represented by exactly one score at its corrected behavior-anchor
bin. A confirmed hard negative is represented by the maximum erroneous-action
score inside its labeled half-open opportunity region. Positives are never
expanded into tolerance windows.
Thresholds for recall-matched false-positive rates must come from disjoint
training-side calibration predictions when Sessions 1--3 are evaluated.
"""
from __future__ import annotations
import argparse
import csv
from dataclasses import dataclass
import json
import math
from pathlib import Path
from typing import Any, Iterable, Literal
import numpy as np
from evaluate_floor_state_changes import (
CLASS_NAMES,
NUM_CLASSES,
NUM_HORIZONS,
OFFICIAL_TRAIN_SESSIONS,
OFFICIAL_TEST_SESSIONS,
STEP_SECONDS,
WEARER_INDEX,
_operating_points,
_safe_ap,
parse_runs,
validate_recalls,
)
SOURCE_FPS = 20.0
Family = Literal["entry", "int"]
FAMILIES: tuple[Family, ...] = ("entry", "int")
@dataclass(frozen=True)
class AnnotatedOpportunity:
uid: str
session: str
family: Family
positive: bool
subtype: str
start_row: int
end_row_exclusive: int
anchor_seconds: float
region_start_seconds: float
region_end_seconds: float
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--reviews",
type=Path,
action="append",
required=True,
help="Human review JSONL. Repeat to combine independently stored files.",
)
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",
default=None,
help="Disjoint training-side human review JSONL used for thresholds.",
)
parser.add_argument(
"--calibration-run",
action="append",
default=None,
metavar="NAME=RESULT_DIR",
help="Calibration inference directory for each --run name.",
)
parser.add_argument(
"--calibration-sessions",
nargs="+",
default=None,
)
parser.add_argument(
"--matched-recalls",
nargs="+",
type=float,
default=(0.7, 0.8, 0.9),
)
parser.add_argument(
"--allow-in-sample-thresholds",
action="store_true",
help="Exploratory only: select thresholds on the evaluated events.",
)
parser.add_argument(
"--output",
type=Path,
default=Path("reports/easycom_annotated_floor_events/summary.json"),
)
parser.add_argument(
"--csv-output",
type=Path,
default=None,
)
parser.add_argument(
"--event-csv-output",
type=Path,
default=None,
help="Optional scored-opportunity CSV (default: OUTPUT stem + _events.csv).",
)
parser.add_argument(
"--print-event-examples",
type=int,
default=0,
metavar="N",
help="Print up to N scored examples per family for the first run.",
)
return parser.parse_args()
def _read_jsonl(paths: Iterable[Path]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for path in paths:
if not path.is_file():
raise FileNotFoundError(path)
with path.open("r", encoding="utf-8") as handle:
for line_number, raw_line in enumerate(handle, 1):
line = raw_line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError as error:
raise ValueError(f"Invalid JSON at {path}:{line_number}: {error}") from error
if not isinstance(row, dict):
raise ValueError(f"Expected object at {path}:{line_number}")
rows.append(row)
return rows
def _integer_frame(value: Any, *, field: str, uid: str) -> int:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{uid}: {field} must be a numeric 20-Hz frame")
rounded = int(round(float(value)))
if not math.isclose(float(value), rounded, abs_tol=1e-6):
raise ValueError(f"{uid}: {field} must be integral, got {value!r}")
if rounded < 0:
raise ValueError(f"{uid}: {field} must be non-negative")
return rounded
def _seconds(value: Any, *, field: str, uid: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{uid}: {field} must be numeric")
result = float(value)
if not math.isfinite(result) or result < 0:
raise ValueError(f"{uid}: {field} must be finite and non-negative")
return result
def _frame_to_row(frame: int) -> int:
"""Use the same 20-Hz-to-5-Hz rasterization as EasyCom event derivation."""
return frame // int(round(SOURCE_FPS * STEP_SECONDS))
def _seconds_to_start_row(seconds: float) -> int:
return int(math.floor(seconds / STEP_SECONDS + 1e-7))
def _seconds_to_end_row_exclusive(seconds: float) -> int:
return int(math.ceil(seconds / STEP_SECONDS - 1e-7))
def _review_payload(row: dict[str, Any], uid: str) -> tuple[dict[str, Any], dict[str, Any]]:
review = row.get("review")
candidate = row.get("candidate")
if not isinstance(review, dict) or not isinstance(candidate, dict):
raise ValueError(
f"{uid}: expected review_floor_events.py JSONL with nested review and candidate objects"
)
if review.get("human_status") != "REVIEWED":
raise ValueError(f"{uid}: human_status must be REVIEWED")
return review, candidate
def load_annotated_opportunities(
paths: Iterable[Path],
sessions: Iterable[str],
) -> tuple[list[AnnotatedOpportunity], dict[str, int]]:
selected_sessions = set(sessions)
opportunities: list[AnnotatedOpportunity] = []
seen: set[str] = set()
excluded = 0
for row_number, row in enumerate(_read_jsonl(paths), 1):
uid = str(row.get("candidate_id") or row.get("candidate_uid") or f"row-{row_number}")
if uid in seen:
raise ValueError(f"Duplicate reviewed candidate: {uid}")
seen.add(uid)
session = str(row.get("session", ""))
if session not in selected_sessions:
continue
review, candidate = _review_payload(row, uid)
verdict = review.get("human_verdict")
if verdict == "EXCLUDE":
excluded += 1
continue
if verdict not in {"POSITIVE", "HARD_NEGATIVE"}:
raise ValueError(f"{uid}: invalid human_verdict {verdict!r}")
family_value = row.get("candidate_family")
if family_value == "entry":
family: Family = "entry"
elif family_value == "int":
family = "int"
else:
raise ValueError(f"{uid}: scoring row has invalid candidate_family {family_value!r}")
positive = verdict == "POSITIVE"
event_type = review.get("human_event_type")
subtype = review.get("human_subtype")
expected_event_type = "WEARER_ENTRY" if family == "entry" else "INT_YIELD"
if positive:
if event_type != expected_event_type or subtype is not None:
raise ValueError(f"{uid}: positive {family} must be {expected_event_type}")
subtype_text = expected_event_type
else:
prefix = "ENTRY_" if family == "entry" else "INT_"
if not isinstance(subtype, str) or (
subtype != "OTHER_HARD_NEGATIVE" and not subtype.startswith(prefix)
):
raise ValueError(f"{uid}: hard-negative subtype does not match {family}")
subtype_text = subtype
correction = review.get("boundary_correction_seconds") or 0.0
correction = float(correction)
correction_rows = int(round(correction / STEP_SECONDS))
if not math.isclose(correction, correction_rows * STEP_SECONDS, abs_tol=1e-7):
raise ValueError(f"{uid}: boundary correction is not on the 5-Hz grid")
evidence = candidate.get("temporal_evidence")
evidence = evidence if isinstance(evidence, dict) else {}
if positive:
raw_frame = evidence.get("behavior_anchor_frame", candidate.get("anchor_dense_frame"))
frame = _integer_frame(raw_frame, field="behavior_anchor_frame", uid=uid)
row_index = _frame_to_row(frame) + correction_rows
if row_index < 0:
raise ValueError(f"{uid}: corrected behavior anchor precedes the session")
anchor_seconds = frame / SOURCE_FPS + correction
start_row = row_index
end_row_exclusive = row_index + 1
region_start = anchor_seconds
region_end = anchor_seconds
else:
raw_start = _seconds(
candidate.get("region_start_seconds", candidate.get("anchor_dense_seconds")),
field="region_start_seconds",
uid=uid,
) + correction
raw_end = _seconds(
candidate.get("region_end_seconds", candidate.get("anchor_dense_seconds")),
field="region_end_seconds",
uid=uid,
) + correction
if raw_start < 0 or raw_end < raw_start:
raise ValueError(f"{uid}: invalid corrected hard-negative region")
start_row = _seconds_to_start_row(raw_start)
end_row_exclusive = max(start_row + 1, _seconds_to_end_row_exclusive(raw_end))
anchor_seconds = _seconds(
candidate.get("anchor_dense_seconds", raw_start - correction),
field="anchor_dense_seconds",
uid=uid,
) + correction
region_start, region_end = raw_start, raw_end
opportunities.append(
AnnotatedOpportunity(
uid=uid,
session=session,
family=family,
positive=positive,
subtype=subtype_text,
start_row=start_row,
end_row_exclusive=end_row_exclusive,
anchor_seconds=anchor_seconds,
region_start_seconds=region_start,
region_end_seconds=region_end,
)
)
diagnostics = {
"num_review_rows": len(seen),
"num_scoring_eligible": len(opportunities),
"num_excluded": excluded,
}
return opportunities, diagnostics
def load_session_predictions(
run_dir: Path,
sessions: Iterable[str],
) -> dict[str, tuple[np.ndarray, np.ndarray]]:
result: dict[str, tuple[np.ndarray, np.ndarray]] = {}
for session in sessions:
pred_path = run_dir / "pred" / f"{session}.npy"
valid_path = run_dir / "valid" / f"{session}.npy"
if not pred_path.is_file():
raise FileNotFoundError(pred_path)
if not valid_path.is_file():
raise FileNotFoundError(valid_path)
prediction = np.asarray(np.load(pred_path, allow_pickle=False), dtype=np.float32)
valid = np.asarray(np.load(valid_path, allow_pickle=False), dtype=bool)
if prediction.ndim != 3 or prediction.shape[1:] != (NUM_CLASSES, NUM_HORIZONS):
raise ValueError(f"Expected [T,{NUM_CLASSES},{NUM_HORIZONS}], got {prediction.shape}: {pred_path}")
if valid.shape != (len(prediction), NUM_HORIZONS):
raise ValueError(f"Expected {(len(prediction), NUM_HORIZONS)}, got {valid.shape}: {valid_path}")
if not np.isfinite(prediction).all() or (prediction < -1e-6).any() or (prediction > 1 + 1e-6).any():
raise ValueError(f"Prediction must be finite and in [0,1]: {pred_path}")
result[session] = (np.clip(prediction, 0.0, 1.0), valid)
return result
def score_opportunity(
opportunity: AnnotatedOpportunity,
arrays: dict[str, tuple[np.ndarray, np.ndarray]],
horizon_index: int,
) -> float | None:
prediction, valid = arrays[opportunity.session]
start = opportunity.start_row
end = opportunity.end_row_exclusive
if start < 0 or start >= end or end > len(prediction):
return None
usable = valid[start:end, horizon_index]
if not usable.all():
return None
scores = prediction[start:end, WEARER_INDEX, horizon_index]
if opportunity.family == "int":
scores = 1.0 - scores
return float(np.max(scores))
def evaluate_family_horizon(
opportunities: list[AnnotatedOpportunity],
arrays: dict[str, tuple[np.ndarray, np.ndarray]],
family: Family,
horizon_index: int,
recalls: list[float],
*,
fixed_thresholds: dict[float, float | None] | None = None,
threshold_source: str = "evaluated_events",
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
rows: list[dict[str, Any]] = []
invalid = 0
for opportunity in opportunities:
if opportunity.family != family:
continue
score = score_opportunity(opportunity, arrays, horizon_index)
if score is None:
invalid += 1
continue
rows.append(
{
"uid": opportunity.uid,
"session": opportunity.session,
"family": family,
"positive": opportunity.positive,
"subtype": opportunity.subtype,
"score": score,
"anchor_seconds": opportunity.anchor_seconds,
"region_start_seconds": opportunity.region_start_seconds,
"region_end_seconds": opportunity.region_end_seconds,
"target_start_row": opportunity.start_row,
"target_end_row_exclusive": opportunity.end_row_exclusive,
"source_start_row": opportunity.start_row - horizon_index - 1,
"source_end_row_exclusive": opportunity.end_row_exclusive - horizon_index - 1,
"nominal_horizon_seconds": (horizon_index + 1) * STEP_SECONDS,
}
)
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
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
}
labels = positive.astype(np.int8)
metric = {
"family": family,
"horizon_index": horizon_index + 1,
"nominal_seconds": (horizon_index + 1) * STEP_SECONDS,
"num_positive": int(positive.sum()),
"num_hard_negative": int(negative.sum()),
"num_invalid": invalid,
"average_precision": _safe_ap(labels, scores),
"operating_points": _operating_points(
scores,
positive,
negative,
recalls,
extra_groups=extra_groups,
fixed_thresholds=fixed_thresholds,
threshold_source=threshold_source,
),
}
return metric, rows
def thresholds_from_metric(metric: dict[str, Any]) -> dict[float, float | None]:
return {
float(item["requested_positive_recall"]): item["threshold"]
for item in metric["operating_points"]
}
def _run_names(runs: list[tuple[str, Path]]) -> list[str]:
return [name for name, _ in runs]
def _validate_protocol_args(args: argparse.Namespace, runs: list[tuple[str, Path]]) -> tuple[list[tuple[str, Path]], bool]:
official_test = bool(set(args.sessions) & set(OFFICIAL_TEST_SESSIONS))
if bool(args.calibration_reviews) != bool(args.calibration_sessions):
raise ValueError(
"--calibration-reviews and --calibration-sessions must be supplied together"
)
has_calibration = bool(args.calibration_reviews and args.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}")
if official_test and not has_calibration and not args.allow_in_sample_thresholds:
raise ValueError(
"Official Sessions 1--3 require --calibration-reviews and "
"--calibration-sessions; use --allow-in-sample-thresholds only for exploration"
)
if args.calibration_run:
calibration_runs = parse_runs(args.calibration_run)
if _run_names(calibration_runs) != _run_names(runs):
raise ValueError("--calibration-run names/order must match --run")
else:
calibration_runs = runs
return calibration_runs, has_calibration
def write_csv(path: Path, run_results: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fieldnames = [
"run", "family", "horizon_index", "nominal_seconds", "num_positive",
"num_hard_negative", "num_invalid", "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 run_results:
for metric in run["horizons"]:
for point in metric["operating_points"]:
writer.writerow(
{
"run": run["name"],
"family": metric["family"],
"horizon_index": metric["horizon_index"],
"nominal_seconds": metric["nominal_seconds"],
"num_positive": metric["num_positive"],
"num_hard_negative": metric["num_hard_negative"],
"num_invalid": metric["num_invalid"],
"average_precision": metric["average_precision"],
"requested_recall": point["requested_positive_recall"],
"threshold": point["threshold"],
"threshold_source": point["threshold_source"],
"achieved_recall": point["achieved_positive_recall"],
"false_positive_rate": point["negative_false_positive_rate"],
"false_positive_count": point["negative_false_positive_count"],
}
)
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",
"horizon_index",
"nominal_horizon_seconds",
"score",
"anchor_seconds",
"region_start_seconds",
"region_end_seconds",
"target_start_row",
"target_end_row_exclusive",
"source_start_row",
"source_end_row_exclusive",
]
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def print_examples(rows: list[dict[str, Any]], limit: int) -> None:
if limit <= 0:
return
print("\nAnnotated fixed-horizon examples (first run, 0.2-s nominal horizon)")
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"target_rows=[{row['target_start_row']},{row['target_end_row_exclusive']}) "
f"source_rows=[{row['source_start_row']},{row['source_end_row_exclusive']}) "
f"region={row['region_start_seconds']:.2f}--{row['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()
recalls = validate_recalls(args.matched_recalls)
runs = parse_runs(args.run)
calibration_runs, has_calibration = _validate_protocol_args(args, runs)
opportunities, review_diagnostics = load_annotated_opportunities(args.reviews, args.sessions)
if not opportunities:
raise ValueError("No scoring-eligible reviewed opportunities for evaluated sessions")
calibration_opportunities: list[AnnotatedOpportunity] | None = None
calibration_diagnostics: dict[str, int] | None = None
if has_calibration:
calibration_opportunities, calibration_diagnostics = load_annotated_opportunities(
args.calibration_reviews,
args.calibration_sessions,
)
if not calibration_opportunities:
raise ValueError("No scoring-eligible calibration opportunities")
all_results: list[dict[str, Any]] = []
all_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 = (
None
if calibration_opportunities is None
else load_session_predictions(calibration_dir, args.calibration_sessions)
)
horizons: list[dict[str, Any]] = []
event_rows: list[dict[str, Any]] = []
for horizon_index in range(NUM_HORIZONS):
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_horizon(
calibration_opportunities,
calibration_arrays,
family,
horizon_index,
recalls,
threshold_source="training_side_calibration",
)
fixed_thresholds = thresholds_from_metric(calibration_metric)
threshold_source = "training_side_calibration"
metric, rows = evaluate_family_horizon(
opportunities,
arrays,
family,
horizon_index,
recalls,
fixed_thresholds=fixed_thresholds,
threshold_source=threshold_source,
)
horizons.append(metric)
event_rows.extend(
{"run": name, **row, "horizon_index": horizon_index + 1}
for row in rows
)
if run_index == 0 and horizon_index == 0:
first_rows.extend(rows)
all_event_rows.extend(event_rows)
all_results.append(
{
"name": name,
"result_dir": str(run_dir),
"horizons": horizons,
"mean_entry_ap": float(np.mean([
item["average_precision"] for item in horizons
if item["family"] == "entry" and item["average_precision"] is not None
])) if any(item["family"] == "entry" and item["average_precision"] is not None for item in horizons) else None,
"mean_int_ap": float(np.mean([
item["average_precision"] for item in horizons
if item["family"] == "int" and item["average_precision"] is not None
])) if any(item["family"] == "int" and item["average_precision"] is not None for item in horizons) else None,
}
)
payload = {
"schema_version": 1,
"protocol": "human-adjudicated-fixed-horizon-floor-events-v1",
"semantic_scope": (
"Human-confirmed wearer entry, INT-yield, and hard-negative opportunities; "
"positive events remain single 5-Hz target bins."
),
"prediction_layout": "[target_time, class, future_horizon]",
"class_names": list(CLASS_NAMES),
"sessions": list(args.sessions),
"calibration_sessions": args.calibration_sessions,
"matched_recalls": recalls,
"positive_score": "one corrected behavior-anchor bin at each nominal horizon",
"hard_negative_score": "maximum erroneous-action score over the labeled half-open region at each nominal horizon",
"review_diagnostics": review_diagnostics,
"calibration_review_diagnostics": calibration_diagnostics,
"runs": all_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")
csv_path = args.csv_output or args.output.with_name(args.output.stem + "_metrics.csv")
event_csv_path = args.event_csv_output or args.output.with_name(
args.output.stem + "_events.csv"
)
write_csv(csv_path, all_results)
write_event_csv(event_csv_path, all_event_rows)
print_examples(first_rows, args.print_event_examples)
print(f"Saved {args.output}")
print(f"Saved {csv_path}")
print(f"Saved {event_csv_path}")
if __name__ == "__main__":
main()