553 lines
20 KiB
Python
553 lines
20 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""Audit EasyCom EOT and wearer interruption/backchannel candidates.
|
||
|
|
|
||
|
|
This is a read-only, TurnBench-inspired candidate audit. EasyCom does not
|
||
|
|
ship human-consensus end-of-turn (EOT), interruption, or backchannel labels,
|
||
|
|
so every automatically derived category is explicitly named a proxy. The
|
||
|
|
script reuses the participant-resolved transcript timeline and the conservative
|
||
|
|
interaction rules in :mod:`derive_easycom_interaction_events`.
|
||
|
|
|
||
|
|
The primary output is a count table. With ``--output-dir`` the script also
|
||
|
|
writes candidate JSONL/CSV files suitable for manual adjudication. It never
|
||
|
|
modifies the source dataset.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import csv
|
||
|
|
import json
|
||
|
|
from collections import Counter
|
||
|
|
from dataclasses import asdict, dataclass
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Sequence
|
||
|
|
|
||
|
|
from derive_easycom_interaction_events import (
|
||
|
|
Event,
|
||
|
|
Rules,
|
||
|
|
Utterance,
|
||
|
|
build_clips,
|
||
|
|
classify_utterance,
|
||
|
|
load_utterances,
|
||
|
|
)
|
||
|
|
from easycom_manifest import ALL_SESSIONS, TEST_SESSIONS
|
||
|
|
|
||
|
|
|
||
|
|
FLOOR_CLAIM_EVENT_TYPES = {
|
||
|
|
"ordinary_switch",
|
||
|
|
"overlap_takeover",
|
||
|
|
"interruption_proxy_high",
|
||
|
|
}
|
||
|
|
NON_FLOOR_EVENT_TYPES = {
|
||
|
|
"backchannel_proxy_high",
|
||
|
|
"backchannel_candidate",
|
||
|
|
"overlap_hold",
|
||
|
|
"nonverbal_feedback",
|
||
|
|
"nonverbal_candidate",
|
||
|
|
}
|
||
|
|
AMBIGUOUS_EVENT_TYPES = {"interruption_candidate"}
|
||
|
|
|
||
|
|
INT_PROXY_GROUPS = {
|
||
|
|
"takeover_proxy": {"overlap_takeover", "interruption_proxy_high"},
|
||
|
|
"backchannel_or_hold_proxy": {
|
||
|
|
"backchannel_proxy_high",
|
||
|
|
"overlap_hold",
|
||
|
|
"nonverbal_feedback",
|
||
|
|
},
|
||
|
|
"ambiguous_overlap_or_feedback": {
|
||
|
|
"interruption_candidate",
|
||
|
|
"backchannel_candidate",
|
||
|
|
"nonverbal_candidate",
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class EOTCandidate:
|
||
|
|
session: str
|
||
|
|
split: str
|
||
|
|
category: str
|
||
|
|
confidence: str
|
||
|
|
holder_uid: str
|
||
|
|
holder_participant_id: int
|
||
|
|
holder_role: str
|
||
|
|
boundary_seconds: float
|
||
|
|
pause_seconds: float | None
|
||
|
|
incoming_uid: str | None
|
||
|
|
incoming_participant_id: int | None
|
||
|
|
incoming_role: str | None
|
||
|
|
incoming_start_seconds: float | None
|
||
|
|
incoming_event_type: str | None
|
||
|
|
reason: str
|
||
|
|
|
||
|
|
|
||
|
|
def _event_map(
|
||
|
|
utterances: Sequence[Utterance],
|
||
|
|
*,
|
||
|
|
camera_wearer_id: int,
|
||
|
|
rules: Rules,
|
||
|
|
) -> dict[str, Event]:
|
||
|
|
return {
|
||
|
|
row.uid: classify_utterance(
|
||
|
|
row,
|
||
|
|
utterances,
|
||
|
|
camera_wearer_id=camera_wearer_id,
|
||
|
|
rules=rules,
|
||
|
|
)
|
||
|
|
for row in utterances
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _next_same_speaker(
|
||
|
|
holder: Utterance,
|
||
|
|
utterances: Sequence[Utterance],
|
||
|
|
*,
|
||
|
|
epsilon: float,
|
||
|
|
) -> Utterance | None:
|
||
|
|
candidates = [
|
||
|
|
row
|
||
|
|
for row in utterances
|
||
|
|
if row.uid != holder.uid
|
||
|
|
and row.segment_id == holder.segment_id
|
||
|
|
and row.participant_id == holder.participant_id
|
||
|
|
and row.dense_start_seconds >= holder.dense_end_seconds - epsilon
|
||
|
|
]
|
||
|
|
return min(candidates, key=lambda row: (row.dense_start_seconds, row.uid), default=None)
|
||
|
|
|
||
|
|
|
||
|
|
def _same_speaker_continues_across_boundary(
|
||
|
|
holder: Utterance,
|
||
|
|
utterances: Sequence[Utterance],
|
||
|
|
*,
|
||
|
|
epsilon: float,
|
||
|
|
) -> bool:
|
||
|
|
boundary = holder.dense_end_seconds
|
||
|
|
return any(
|
||
|
|
row.uid != holder.uid
|
||
|
|
and row.segment_id == holder.segment_id
|
||
|
|
and row.participant_id == holder.participant_id
|
||
|
|
and row.dense_start_seconds < boundary - epsilon
|
||
|
|
and row.dense_end_seconds > boundary + epsilon
|
||
|
|
for row in utterances
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _incoming_before_resume(
|
||
|
|
holder: Utterance,
|
||
|
|
utterances: Sequence[Utterance],
|
||
|
|
*,
|
||
|
|
resume: Utterance | None,
|
||
|
|
epsilon: float,
|
||
|
|
) -> list[Utterance]:
|
||
|
|
boundary = holder.dense_end_seconds
|
||
|
|
resume_start = float("inf") if resume is None else resume.dense_start_seconds
|
||
|
|
candidates = []
|
||
|
|
for row in utterances:
|
||
|
|
if row.segment_id != holder.segment_id or row.participant_id == holder.participant_id:
|
||
|
|
continue
|
||
|
|
active_across_boundary = (
|
||
|
|
row.dense_start_seconds < boundary - epsilon
|
||
|
|
and row.dense_end_seconds > boundary + epsilon
|
||
|
|
)
|
||
|
|
starts_after_boundary = row.dense_start_seconds >= boundary - epsilon
|
||
|
|
if not (active_across_boundary or starts_after_boundary):
|
||
|
|
continue
|
||
|
|
if row.dense_start_seconds > resume_start + epsilon:
|
||
|
|
continue
|
||
|
|
candidates.append(row)
|
||
|
|
return sorted(candidates, key=lambda row: (max(boundary, row.dense_start_seconds), row.uid))
|
||
|
|
|
||
|
|
|
||
|
|
def derive_eot_candidates(
|
||
|
|
utterances: Sequence[Utterance],
|
||
|
|
*,
|
||
|
|
camera_wearer_id: int,
|
||
|
|
rules: Rules,
|
||
|
|
min_pause_seconds: float = 0.1,
|
||
|
|
max_pause_seconds: float = 2.0,
|
||
|
|
epsilon: float = 1e-6,
|
||
|
|
) -> list[EOTCandidate]:
|
||
|
|
"""Derive conservative EOT positive/negative candidates.
|
||
|
|
|
||
|
|
A positive proxy requires another participant to claim the floor no later
|
||
|
|
than ``rules.max_switch_gap_seconds`` after the holder stops. A hard
|
||
|
|
negative is a pause of ``[min_pause_seconds, max_pause_seconds]`` after
|
||
|
|
which the same holder resumes with no intervening floor claim. Long gaps,
|
||
|
|
terminal boundaries, annotation continuations, and disputed overlap are
|
||
|
|
kept as ambiguous/excluded rather than silently becoming labels.
|
||
|
|
"""
|
||
|
|
|
||
|
|
if min_pause_seconds < 0 or max_pause_seconds < min_pause_seconds:
|
||
|
|
raise ValueError("Require 0 <= min_pause_seconds <= max_pause_seconds")
|
||
|
|
event_by_uid = _event_map(
|
||
|
|
utterances,
|
||
|
|
camera_wearer_id=camera_wearer_id,
|
||
|
|
rules=rules,
|
||
|
|
)
|
||
|
|
results: list[EOTCandidate] = []
|
||
|
|
|
||
|
|
for holder in utterances:
|
||
|
|
split = "test" if holder.session in TEST_SESSIONS else "train"
|
||
|
|
holder_role = "wearer" if holder.participant_id == camera_wearer_id else "other"
|
||
|
|
boundary = holder.dense_end_seconds
|
||
|
|
|
||
|
|
def make(
|
||
|
|
category: str,
|
||
|
|
confidence: str,
|
||
|
|
reason: str,
|
||
|
|
*,
|
||
|
|
pause: float | None = None,
|
||
|
|
incoming: Utterance | None = None,
|
||
|
|
) -> EOTCandidate:
|
||
|
|
incoming_event = None if incoming is None else event_by_uid[incoming.uid]
|
||
|
|
return EOTCandidate(
|
||
|
|
session=holder.session,
|
||
|
|
split=split,
|
||
|
|
category=category,
|
||
|
|
confidence=confidence,
|
||
|
|
holder_uid=holder.uid,
|
||
|
|
holder_participant_id=holder.participant_id,
|
||
|
|
holder_role=holder_role,
|
||
|
|
boundary_seconds=boundary,
|
||
|
|
pause_seconds=pause,
|
||
|
|
incoming_uid=None if incoming is None else incoming.uid,
|
||
|
|
incoming_participant_id=(
|
||
|
|
None if incoming is None else incoming.participant_id
|
||
|
|
),
|
||
|
|
incoming_role=(
|
||
|
|
None
|
||
|
|
if incoming is None
|
||
|
|
else ("wearer" if incoming.participant_id == camera_wearer_id else "other")
|
||
|
|
),
|
||
|
|
incoming_start_seconds=(
|
||
|
|
None if incoming is None else incoming.dense_start_seconds
|
||
|
|
),
|
||
|
|
incoming_event_type=(
|
||
|
|
None if incoming_event is None else incoming_event.event_type
|
||
|
|
),
|
||
|
|
reason=reason,
|
||
|
|
)
|
||
|
|
|
||
|
|
if _same_speaker_continues_across_boundary(
|
||
|
|
holder,
|
||
|
|
utterances,
|
||
|
|
epsilon=epsilon,
|
||
|
|
):
|
||
|
|
results.append(make(
|
||
|
|
"eot_excluded_internal_boundary",
|
||
|
|
"excluded",
|
||
|
|
"another annotation for the same speaker continues across this endpoint",
|
||
|
|
))
|
||
|
|
continue
|
||
|
|
|
||
|
|
resume = _next_same_speaker(holder, utterances, epsilon=epsilon)
|
||
|
|
pause = None if resume is None else max(0.0, resume.dense_start_seconds - boundary)
|
||
|
|
if (
|
||
|
|
resume is not None
|
||
|
|
and pause <= min_pause_seconds + epsilon
|
||
|
|
and (resume.is_cross_clip_continuation or holder.ends_at_clip_boundary)
|
||
|
|
):
|
||
|
|
results.append(make(
|
||
|
|
"eot_excluded_file_continuation",
|
||
|
|
"excluded",
|
||
|
|
"same-speaker activity continues across an adjacent file boundary",
|
||
|
|
pause=pause,
|
||
|
|
incoming=resume,
|
||
|
|
))
|
||
|
|
continue
|
||
|
|
|
||
|
|
incoming_rows = _incoming_before_resume(
|
||
|
|
holder,
|
||
|
|
utterances,
|
||
|
|
resume=resume,
|
||
|
|
epsilon=epsilon,
|
||
|
|
)
|
||
|
|
floor_claims = [
|
||
|
|
row
|
||
|
|
for row in incoming_rows
|
||
|
|
if event_by_uid[row.uid].event_type in FLOOR_CLAIM_EVENT_TYPES
|
||
|
|
]
|
||
|
|
ambiguous = [
|
||
|
|
row
|
||
|
|
for row in incoming_rows
|
||
|
|
if event_by_uid[row.uid].event_type in AMBIGUOUS_EVENT_TYPES
|
||
|
|
]
|
||
|
|
unresolved = [
|
||
|
|
row
|
||
|
|
for row in incoming_rows
|
||
|
|
if event_by_uid[row.uid].event_type
|
||
|
|
not in FLOOR_CLAIM_EVENT_TYPES
|
||
|
|
| NON_FLOOR_EVENT_TYPES
|
||
|
|
| AMBIGUOUS_EVENT_TYPES
|
||
|
|
| {"cross_clip_continuation"}
|
||
|
|
]
|
||
|
|
|
||
|
|
if floor_claims:
|
||
|
|
incoming = floor_claims[0]
|
||
|
|
gap = incoming.dense_start_seconds - boundary
|
||
|
|
if gap <= rules.max_switch_gap_seconds + epsilon:
|
||
|
|
incoming_type = event_by_uid[incoming.uid].event_type
|
||
|
|
category = (
|
||
|
|
"eot_positive_overlap_takeover_proxy"
|
||
|
|
if incoming.dense_start_seconds < boundary - epsilon
|
||
|
|
else "eot_positive_handover_proxy"
|
||
|
|
)
|
||
|
|
results.append(make(
|
||
|
|
category,
|
||
|
|
"timing_proxy",
|
||
|
|
f"incoming {incoming_type} claims the floor within the allowed gap",
|
||
|
|
pause=max(0.0, gap),
|
||
|
|
incoming=incoming,
|
||
|
|
))
|
||
|
|
else:
|
||
|
|
results.append(make(
|
||
|
|
"eot_ambiguous_long_gap_before_handover",
|
||
|
|
"ambiguous",
|
||
|
|
"another speaker eventually claims the floor, but only after a long gap",
|
||
|
|
pause=gap,
|
||
|
|
incoming=incoming,
|
||
|
|
))
|
||
|
|
continue
|
||
|
|
|
||
|
|
if ambiguous or unresolved:
|
||
|
|
incoming = sorted(
|
||
|
|
[*ambiguous, *unresolved],
|
||
|
|
key=lambda row: (max(boundary, row.dense_start_seconds), row.uid),
|
||
|
|
)[0]
|
||
|
|
incoming_type = event_by_uid[incoming.uid].event_type
|
||
|
|
gap = incoming.dense_start_seconds - boundary
|
||
|
|
if incoming.dense_start_seconds < boundary - epsilon:
|
||
|
|
category = "eot_ambiguous_overlap"
|
||
|
|
elif gap > rules.max_switch_gap_seconds + epsilon:
|
||
|
|
category = "eot_ambiguous_long_gap_before_handover"
|
||
|
|
else:
|
||
|
|
category = "eot_ambiguous_short_or_unresolved_incoming"
|
||
|
|
results.append(make(
|
||
|
|
category,
|
||
|
|
"ambiguous",
|
||
|
|
f"an unresolved incoming event ({incoming_type}) occurs before the holder resumes",
|
||
|
|
pause=max(0.0, gap),
|
||
|
|
incoming=incoming,
|
||
|
|
))
|
||
|
|
continue
|
||
|
|
|
||
|
|
if resume is not None:
|
||
|
|
if pause < min_pause_seconds - epsilon:
|
||
|
|
category = "eot_excluded_annotation_seam"
|
||
|
|
confidence = "excluded"
|
||
|
|
reason = "same speaker resumes too quickly to form a meaningful pause"
|
||
|
|
elif pause <= max_pause_seconds + epsilon:
|
||
|
|
category = "eot_negative_mid_turn_pause_proxy"
|
||
|
|
confidence = "timing_proxy"
|
||
|
|
reason = "same speaker resumes within the pause limit with no floor claim"
|
||
|
|
else:
|
||
|
|
category = "eot_ambiguous_long_same_speaker_gap"
|
||
|
|
confidence = "ambiguous"
|
||
|
|
reason = "same speaker resumes after a long gap; turn continuity is unknown"
|
||
|
|
results.append(make(
|
||
|
|
category,
|
||
|
|
confidence,
|
||
|
|
reason,
|
||
|
|
pause=pause,
|
||
|
|
incoming=resume,
|
||
|
|
))
|
||
|
|
continue
|
||
|
|
|
||
|
|
# TurnBench counts a speaker's final turn as EOT. We deliberately do
|
||
|
|
# not: EasyCom clips and redacted source gaps make the endpoint
|
||
|
|
# semantically unknown without manual review.
|
||
|
|
results.append(make(
|
||
|
|
"eot_excluded_terminal_or_gap",
|
||
|
|
"excluded",
|
||
|
|
"no later same-speaker or floor-claim evidence in this contiguous segment",
|
||
|
|
))
|
||
|
|
|
||
|
|
return results
|
||
|
|
|
||
|
|
|
||
|
|
def _serialize_event(event: Event) -> dict:
|
||
|
|
payload = asdict(event)
|
||
|
|
payload["target_of_speech"] = list(event.target_of_speech)
|
||
|
|
return payload
|
||
|
|
|
||
|
|
|
||
|
|
def _write_rows(path: Path, rows: list[dict]) -> None:
|
||
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
if path.suffix == ".jsonl":
|
||
|
|
with path.open("w", encoding="utf-8") as handle:
|
||
|
|
for row in rows:
|
||
|
|
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||
|
|
return
|
||
|
|
fieldnames = list(rows[0]) if rows else []
|
||
|
|
with path.open("w", encoding="utf-8", newline="") as handle:
|
||
|
|
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
||
|
|
if fieldnames:
|
||
|
|
writer.writeheader()
|
||
|
|
for row in rows:
|
||
|
|
encoded = {
|
||
|
|
key: json.dumps(value, ensure_ascii=False)
|
||
|
|
if isinstance(value, (list, tuple, dict))
|
||
|
|
else value
|
||
|
|
for key, value in row.items()
|
||
|
|
}
|
||
|
|
writer.writerow(encoded)
|
||
|
|
|
||
|
|
|
||
|
|
def _print_counts(title: str, counts: Counter) -> None:
|
||
|
|
print(title)
|
||
|
|
for name, count in sorted(counts.items()):
|
||
|
|
print(f" {name:<44} {count:>6}")
|
||
|
|
|
||
|
|
|
||
|
|
def parse_args() -> argparse.Namespace:
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
parser.add_argument("--dataset-root", type=Path, required=True)
|
||
|
|
parser.add_argument("--sessions", nargs="+", default=list(ALL_SESSIONS))
|
||
|
|
parser.add_argument("--camera-wearer-id", type=int, default=2)
|
||
|
|
parser.add_argument("--output-dir", type=Path)
|
||
|
|
parser.add_argument("--min-pause-seconds", type=float, default=0.1)
|
||
|
|
parser.add_argument("--max-pause-seconds", type=float, default=2.0)
|
||
|
|
parser.add_argument("--context-seconds", type=float, default=1.5)
|
||
|
|
parser.add_argument("--backchannel-max-seconds", type=float, default=1.0)
|
||
|
|
parser.add_argument("--max-switch-gap-seconds", type=float, default=0.6)
|
||
|
|
parser.add_argument("--min-overlap-seconds", type=float, default=0.1)
|
||
|
|
parser.add_argument("--takeover-candidate-seconds", type=float, default=0.4)
|
||
|
|
parser.add_argument("--takeover-high-seconds", type=float, default=0.8)
|
||
|
|
parser.add_argument("--max-contiguous-source-gap-seconds", type=float, default=1.0)
|
||
|
|
return parser.parse_args()
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> None:
|
||
|
|
args = parse_args()
|
||
|
|
unknown = sorted(set(args.sessions) - set(ALL_SESSIONS))
|
||
|
|
if unknown:
|
||
|
|
raise ValueError(f"Unknown EasyCom sessions: {unknown}")
|
||
|
|
rules = Rules(
|
||
|
|
context_seconds=args.context_seconds,
|
||
|
|
backchannel_max_seconds=args.backchannel_max_seconds,
|
||
|
|
max_switch_gap_seconds=args.max_switch_gap_seconds,
|
||
|
|
min_overlap_seconds=args.min_overlap_seconds,
|
||
|
|
takeover_candidate_seconds=args.takeover_candidate_seconds,
|
||
|
|
takeover_high_seconds=args.takeover_high_seconds,
|
||
|
|
max_contiguous_source_gap_seconds=args.max_contiguous_source_gap_seconds,
|
||
|
|
)
|
||
|
|
|
||
|
|
all_eot: list[EOTCandidate] = []
|
||
|
|
all_events: list[Event] = []
|
||
|
|
for session in args.sessions:
|
||
|
|
clips = build_clips(args.dataset_root.resolve(), session, rules)
|
||
|
|
utterances = load_utterances(session, clips)
|
||
|
|
events = list(_event_map(
|
||
|
|
utterances,
|
||
|
|
camera_wearer_id=args.camera_wearer_id,
|
||
|
|
rules=rules,
|
||
|
|
).values())
|
||
|
|
eot = derive_eot_candidates(
|
||
|
|
utterances,
|
||
|
|
camera_wearer_id=args.camera_wearer_id,
|
||
|
|
rules=rules,
|
||
|
|
min_pause_seconds=args.min_pause_seconds,
|
||
|
|
max_pause_seconds=args.max_pause_seconds,
|
||
|
|
)
|
||
|
|
all_events.extend(events)
|
||
|
|
all_eot.extend(eot)
|
||
|
|
print(f"{session}: utterances={len(utterances)} eot_boundaries={len(eot)}")
|
||
|
|
|
||
|
|
for split in ("train", "test", "all"):
|
||
|
|
selected_eot = [row for row in all_eot if split == "all" or row.split == split]
|
||
|
|
selected_events = [row for row in all_events if split == "all" or row.split == split]
|
||
|
|
print(f"\n[{split}]")
|
||
|
|
_print_counts(
|
||
|
|
"Other-floor EOT candidates",
|
||
|
|
Counter(row.category for row in selected_eot if row.holder_role == "other"),
|
||
|
|
)
|
||
|
|
_print_counts(
|
||
|
|
"All-holder EOT candidates",
|
||
|
|
Counter(row.category for row in selected_eot),
|
||
|
|
)
|
||
|
|
int_counts = Counter()
|
||
|
|
for event in selected_events:
|
||
|
|
if event.participant_role != "wearer":
|
||
|
|
continue
|
||
|
|
for group, names in INT_PROXY_GROUPS.items():
|
||
|
|
if event.event_type in names:
|
||
|
|
int_counts[group] += 1
|
||
|
|
int_counts[f"{group}/{event.event_type}"] += 1
|
||
|
|
break
|
||
|
|
_print_counts("Wearer takeover/backchannel candidates", int_counts)
|
||
|
|
|
||
|
|
if args.output_dir is not None:
|
||
|
|
output = args.output_dir.resolve()
|
||
|
|
eot_rows = [
|
||
|
|
asdict(row)
|
||
|
|
for row in all_eot
|
||
|
|
if row.holder_role == "other"
|
||
|
|
and not row.category.startswith("eot_excluded_")
|
||
|
|
]
|
||
|
|
int_rows = []
|
||
|
|
candidate_names = set().union(*INT_PROXY_GROUPS.values())
|
||
|
|
for event in all_events:
|
||
|
|
if event.participant_role == "wearer" and event.event_type in candidate_names:
|
||
|
|
row = _serialize_event(event)
|
||
|
|
row["proxy_group"] = next(
|
||
|
|
group for group, names in INT_PROXY_GROUPS.items()
|
||
|
|
if event.event_type in names
|
||
|
|
)
|
||
|
|
int_rows.append(row)
|
||
|
|
_write_rows(output / "easycom_eot_candidates.jsonl", eot_rows)
|
||
|
|
_write_rows(output / "easycom_eot_candidates.csv", eot_rows)
|
||
|
|
_write_rows(output / "easycom_wearer_int_candidates.jsonl", int_rows)
|
||
|
|
_write_rows(output / "easycom_wearer_int_candidates.csv", int_rows)
|
||
|
|
split_summaries = {}
|
||
|
|
candidate_names = set().union(*INT_PROXY_GROUPS.values())
|
||
|
|
for split in ("train", "test", "all"):
|
||
|
|
selected_eot = [
|
||
|
|
row for row in all_eot if split == "all" or row.split == split
|
||
|
|
]
|
||
|
|
selected_events = [
|
||
|
|
event for event in all_events if split == "all" or event.split == split
|
||
|
|
]
|
||
|
|
proxy_groups = Counter()
|
||
|
|
for event in selected_events:
|
||
|
|
if event.participant_role != "wearer":
|
||
|
|
continue
|
||
|
|
for group, names in INT_PROXY_GROUPS.items():
|
||
|
|
if event.event_type in names:
|
||
|
|
proxy_groups[group] += 1
|
||
|
|
break
|
||
|
|
split_summaries[split] = {
|
||
|
|
"other_floor_eot_counts": dict(sorted(Counter(
|
||
|
|
row.category for row in selected_eot if row.holder_role == "other"
|
||
|
|
).items())),
|
||
|
|
"wearer_proxy_group_counts": dict(sorted(proxy_groups.items())),
|
||
|
|
"wearer_event_type_counts": dict(sorted(Counter(
|
||
|
|
event.event_type
|
||
|
|
for event in selected_events
|
||
|
|
if event.participant_role == "wearer"
|
||
|
|
and event.event_type in candidate_names
|
||
|
|
).items())),
|
||
|
|
}
|
||
|
|
summary = {
|
||
|
|
"schema_version": 1,
|
||
|
|
"dataset": "EasyCom",
|
||
|
|
"semantic_warning": (
|
||
|
|
"All EOT, takeover, interruption, and backchannel categories are "
|
||
|
|
"timing/transcript-derived proxies, not human-consensus labels."
|
||
|
|
),
|
||
|
|
"sessions": args.sessions,
|
||
|
|
"rules": asdict(rules),
|
||
|
|
"min_pause_seconds": args.min_pause_seconds,
|
||
|
|
"max_pause_seconds": args.max_pause_seconds,
|
||
|
|
"splits": split_summaries,
|
||
|
|
}
|
||
|
|
output.mkdir(parents=True, exist_ok=True)
|
||
|
|
(output / "summary.json").write_text(
|
||
|
|
json.dumps(summary, indent=2, ensure_ascii=False) + "\n",
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
print(f"\nSaved proxy audit to {output}")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|