Add EasyCom interaction weak-label pipeline
This commit is contained in:
117
README.md
117
README.md
@@ -1658,6 +1658,123 @@ semantics. Run the following controlled experiments:
|
|||||||
The frozen-Omni head is the cleanest representation test. LoRA can be reported
|
The frozen-Omni head is the cleanest representation test. LoRA can be reported
|
||||||
as a second-stage ceiling, not as the only result.
|
as a second-stage ceiling, not as the only result.
|
||||||
|
|
||||||
|
## Transcript-derived interaction-event proxies
|
||||||
|
|
||||||
|
EasyCom's released human transcriptions contain inclusive 20 FPS
|
||||||
|
`Start_Frame`/`End_Frame`, `Participant_ID`, `Transcription`, and
|
||||||
|
`Target_of_Speech`. They do **not** contain a semantic backchannel class.
|
||||||
|
[`derive_easycom_interaction_events.py`](derive_easycom_interaction_events.py)
|
||||||
|
therefore separates observed timing relations from weak semantic proxies:
|
||||||
|
|
||||||
|
- `ordinary_switch`: a non-overlapping recent speaker change followed by a
|
||||||
|
sustained incoming turn;
|
||||||
|
- `overlap_takeover`: an observed overlapping onset followed by sustained
|
||||||
|
takeover, without claiming interruption intent;
|
||||||
|
- `overlap_hold`: overlap without enough evidence for takeover;
|
||||||
|
- `backchannel_proxy_high`: a conservative short lexical response while the
|
||||||
|
same other participant dominates immediately before and after it;
|
||||||
|
- `interruption_proxy_high`: the previous transcript has a non-boundary
|
||||||
|
trailing cutoff and the incoming participant takes over;
|
||||||
|
- `nonverbal_feedback`: laughter-only feedback around another participant's
|
||||||
|
floor; pure `[H]`, `[C]`, `[T]`, and `[U]` records are ambiguous candidates.
|
||||||
|
|
||||||
|
`backchannel_candidate`, `interruption_candidate`, and
|
||||||
|
`nonverbal_candidate` are retained for auditing but masked from auxiliary
|
||||||
|
training. `[X]` continuations across adjacent one-minute files are not new
|
||||||
|
events. Source-time gaps larger than one second create separate segments, so
|
||||||
|
the dense EgoSpeak concatenation does not create a false switch across a
|
||||||
|
redacted interval. A one- or two-frame annotation/media endpoint discrepancy
|
||||||
|
is clamped and reported; larger disagreement is fatal.
|
||||||
|
|
||||||
|
A read-only audit on the raw-data machine is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/58462F4C462F29E8/Code/icassp2027-experiment
|
||||||
|
|
||||||
|
python3 derive_easycom_interaction_events.py \
|
||||||
|
--dataset-root /mnt/58462F4C462F29E8/Datasets/easycom \
|
||||||
|
--target-root /mnt/58462F4C462F29E8/Code/EgoSpeak/dataset/EasyCom/target_perframe \
|
||||||
|
--examples-per-event 10
|
||||||
|
```
|
||||||
|
|
||||||
|
To persist JSONL/CSV reports outside the dataset, add:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
--output-dir reports/easycom_interaction_events_v1 \
|
||||||
|
--overwrite
|
||||||
|
```
|
||||||
|
|
||||||
|
In an environment with NumPy, add `--write-frame-arrays` to also write one
|
||||||
|
`Session_*.aux_targets.npz` per session. It contains 5 Hz `event_onsets`,
|
||||||
|
`event_roles`, collision flags, and event-name metadata aligned to
|
||||||
|
`target_perframe`. The raw EasyCom tree is never modified.
|
||||||
|
|
||||||
|
The initial all-session audit found 6,981 utterances, including 530
|
||||||
|
high-confidence backchannel proxies, 78 high-confidence interruption proxies,
|
||||||
|
937 overlap-takeovers, 686 overlap-holds, and 349 laughter-feedback events.
|
||||||
|
These counts establish usable weak-supervision coverage, not label accuracy.
|
||||||
|
Before training, inspect a stratified sample from Sessions 4--12 and freeze the
|
||||||
|
rules; do not tune thresholds on test Sessions 1--3. The primary evaluator
|
||||||
|
remains the original EgoSpeak three-label AP/mAP protocol.
|
||||||
|
|
||||||
|
### Optional DeepSeek adjudication of ambiguous candidates
|
||||||
|
|
||||||
|
DeepSeek is not required for the deterministic timing labels above. It is an
|
||||||
|
optional second-stage weak-label adjudicator for only
|
||||||
|
`backchannel_candidate` and `interruption_candidate`; sending all 6,981
|
||||||
|
utterances to an LLM would add cost and noise without adding observable
|
||||||
|
prosody, gaze, or intent. The current audit contains 614 such candidates over
|
||||||
|
all sessions and 497 in the training split (Sessions 4--12).
|
||||||
|
|
||||||
|
[`adjudicate_easycom_interaction_events.py`](adjudicate_easycom_interaction_events.py)
|
||||||
|
uses DeepSeek JSON Output through the standard-library HTTP client, so no
|
||||||
|
additional Python package is required. It makes two differently instructed
|
||||||
|
conservative passes. A candidate is promoted only if both passes return the candidate's
|
||||||
|
matching semantic label with high confidence; disagreements, medium/low
|
||||||
|
confidence, short answers, cooperative overlaps, and uncertain cases remain
|
||||||
|
masked. The result is still a weak proxy and must not be presented as human
|
||||||
|
ground truth. The append-only JSONL records prompt/model metadata and resumes
|
||||||
|
completed candidates, while rejecting a mixed model or prompt configuration.
|
||||||
|
|
||||||
|
First inspect exactly what would leave the machine. This makes no API call and
|
||||||
|
does not require a key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 adjudicate_easycom_interaction_events.py \
|
||||||
|
--input-dir reports/easycom_interaction_events_v1 \
|
||||||
|
--output reports/easycom_interaction_llm_v1.jsonl \
|
||||||
|
--max-events 16 \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
If external transcript upload is authorized, enter the key without placing it
|
||||||
|
in shell history and run a 32-candidate pilot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
read -s -p "DeepSeek API key: " DEEPSEEK_API_KEY; echo
|
||||||
|
export DEEPSEEK_API_KEY
|
||||||
|
|
||||||
|
python3 adjudicate_easycom_interaction_events.py \
|
||||||
|
--input-dir reports/easycom_interaction_events_v1 \
|
||||||
|
--output reports/easycom_interaction_llm_pilot_v1.jsonl \
|
||||||
|
--max-events 32 \
|
||||||
|
--selection-seed 2027 \
|
||||||
|
--batch-size 8 \
|
||||||
|
--passes 2 \
|
||||||
|
--allow-external-transcript-upload
|
||||||
|
|
||||||
|
unset DEEPSEEK_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit the pilot rationales and precision manually before running the full
|
||||||
|
training split. For the full pass, use a new output path, remove
|
||||||
|
`--max-events`, and keep the default `--splits train`. The default model is
|
||||||
|
`deepseek-v4-flash`; it can be changed with `--model` or `DEEPSEEK_MODEL` as
|
||||||
|
the provider's available model names evolve. Never tune the prompt or
|
||||||
|
acceptance rule using Sessions 1--3. LLM-adjudicated candidates are deliberately
|
||||||
|
kept in a separate JSONL until pilot precision is accepted; they are not yet
|
||||||
|
silently merged into the training arrays.
|
||||||
|
|
||||||
## Candidate improvement: explicit working and long memory
|
## Candidate improvement: explicit working and long memory
|
||||||
|
|
||||||
Motivated by EgoSpeak's separation of long-term and working memory, test an
|
Motivated by EgoSpeak's separation of long-term and working memory, test an
|
||||||
|
|||||||
461
adjudicate_easycom_interaction_events.py
Normal file
461
adjudicate_easycom_interaction_events.py
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Optionally adjudicate ambiguous EasyCom interaction proxies with DeepSeek.
|
||||||
|
|
||||||
|
This is a second-stage *weak-label adjudicator*, not a replacement for the
|
||||||
|
timestamp rules in ``derive_easycom_interaction_events.py``. By default it
|
||||||
|
only reviews ambiguous training candidates and promotes a semantic label when
|
||||||
|
two passes agree and both report high confidence.
|
||||||
|
|
||||||
|
Transcript excerpts are sent to an external API only when the caller passes
|
||||||
|
``--allow-external-transcript-upload``. The API key is read exclusively from
|
||||||
|
``DEEPSEEK_API_KEY`` and is never written to output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT_VERSION = "easycom-interaction-adjudicator-v1"
|
||||||
|
OUTPUT_SCHEMA_VERSION = 1
|
||||||
|
DEFAULT_BASE_URL = "https://api.deepseek.com"
|
||||||
|
DEFAULT_MODEL = "deepseek-v4-flash"
|
||||||
|
DEFAULT_EVENT_TYPES = ("backchannel_candidate", "interruption_candidate")
|
||||||
|
ALLOWED_LABELS = {
|
||||||
|
"backchannel",
|
||||||
|
"short_answer_or_response",
|
||||||
|
"interruption",
|
||||||
|
"cooperative_overlap_takeover",
|
||||||
|
"other",
|
||||||
|
"uncertain",
|
||||||
|
}
|
||||||
|
ALLOWED_CONFIDENCE = {"high", "medium", "low"}
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """You are adjudicating weak labels for conversational turn-taking research.
|
||||||
|
Use only the supplied transcript and timing evidence. Do not infer prosody, gaze, or intention that is not observed.
|
||||||
|
|
||||||
|
Definitions:
|
||||||
|
- backchannel: a brief acknowledgement/feedback that does not take the conversational floor.
|
||||||
|
- short_answer_or_response: a brief but content-bearing answer/response, not merely feedback.
|
||||||
|
- interruption: the incoming speaker competitively cuts off the previous floor holder and takes the floor.
|
||||||
|
- cooperative_overlap_takeover: overlapping floor transfer without enough evidence of competitive interruption.
|
||||||
|
- other: none of the above.
|
||||||
|
- uncertain: the available text/timing cannot reliably distinguish the labels.
|
||||||
|
|
||||||
|
Be conservative. Timing overlap alone does not prove interruption. A short word alone does not prove backchannel.
|
||||||
|
Return one JSON object exactly in this form:
|
||||||
|
{"items":[{"uid":"...","label":"uncertain","confidence":"low","rationale":"brief evidence-based sentence"}]}
|
||||||
|
Return every input uid exactly once. The response must be JSON, with no markdown or extra text."""
|
||||||
|
|
||||||
|
|
||||||
|
def _load_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
for line_number, line in enumerate(handle, start=1):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
row = json.loads(line)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(f"Invalid JSON at {path}:{line_number}: {exc}") from exc
|
||||||
|
if not isinstance(row, dict) or not isinstance(row.get("uid"), str):
|
||||||
|
raise ValueError(f"Expected object with uid at {path}:{line_number}")
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def load_candidates(
|
||||||
|
input_dir: Path,
|
||||||
|
*,
|
||||||
|
splits: set[str],
|
||||||
|
event_types: set[str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
paths = sorted(input_dir.glob("Session_*.events.jsonl"))
|
||||||
|
if not paths:
|
||||||
|
raise FileNotFoundError(f"No Session_*.events.jsonl files in {input_dir}")
|
||||||
|
candidates: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for path in paths:
|
||||||
|
for row in _load_jsonl(path):
|
||||||
|
uid = row["uid"]
|
||||||
|
if uid in seen:
|
||||||
|
raise ValueError(f"Duplicate event uid: {uid}")
|
||||||
|
seen.add(uid)
|
||||||
|
if row.get("split") in splits and row.get("event_type") in event_types:
|
||||||
|
candidates.append(row)
|
||||||
|
return sorted(candidates, key=lambda row: row["uid"])
|
||||||
|
|
||||||
|
|
||||||
|
def stratified_limit(
|
||||||
|
rows: Sequence[dict[str, Any]],
|
||||||
|
limit: int | None,
|
||||||
|
*,
|
||||||
|
seed: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Select a deterministic pilot across sessions and candidate types."""
|
||||||
|
|
||||||
|
if limit is None or limit >= len(rows):
|
||||||
|
return list(rows)
|
||||||
|
if limit < 0:
|
||||||
|
raise ValueError("--max-events must be non-negative")
|
||||||
|
rng = random.Random(seed)
|
||||||
|
buckets: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
buckets[(str(row.get("event_type")), str(row.get("session")))].append(row)
|
||||||
|
keys = sorted(buckets)
|
||||||
|
for key in keys:
|
||||||
|
rng.shuffle(buckets[key])
|
||||||
|
rng.shuffle(keys)
|
||||||
|
selected: list[dict[str, Any]] = []
|
||||||
|
while len(selected) < limit:
|
||||||
|
made_progress = False
|
||||||
|
for key in keys:
|
||||||
|
if buckets[key]:
|
||||||
|
selected.append(buckets[key].pop())
|
||||||
|
made_progress = True
|
||||||
|
if len(selected) == limit:
|
||||||
|
break
|
||||||
|
if not made_progress:
|
||||||
|
break
|
||||||
|
return sorted(selected, key=lambda row: row["uid"])
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_for_prompt(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
fields = (
|
||||||
|
"uid",
|
||||||
|
"event_type",
|
||||||
|
"participant_role",
|
||||||
|
"participant_id",
|
||||||
|
"previous_floor_participant_id",
|
||||||
|
"retained_floor_participant_id",
|
||||||
|
"target_matches_previous_floor",
|
||||||
|
"duration_seconds",
|
||||||
|
"onset_overlap_seconds",
|
||||||
|
"gap_from_previous_seconds",
|
||||||
|
"speaker_after_previous_end_seconds",
|
||||||
|
"previous_transcript_has_cutoff",
|
||||||
|
"previous_transcription",
|
||||||
|
"transcription",
|
||||||
|
"target_of_speech",
|
||||||
|
)
|
||||||
|
return {field: row.get(field) for field in fields}
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_prompt(rows: Sequence[dict[str, Any]], pass_index: int) -> str:
|
||||||
|
if pass_index == 0:
|
||||||
|
review = "Primary pass: classify conservatively from the supplied evidence."
|
||||||
|
else:
|
||||||
|
review = (
|
||||||
|
"Skeptical review pass: actively look for alternative explanations. "
|
||||||
|
"Use uncertain unless the semantic label remains well supported."
|
||||||
|
)
|
||||||
|
payload = [_candidate_for_prompt(row) for row in rows]
|
||||||
|
return (
|
||||||
|
review
|
||||||
|
+ "\nReturn JSON for these candidates:\n"
|
||||||
|
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_hash(rows: Sequence[dict[str, Any]], passes: int) -> str:
|
||||||
|
payload = {
|
||||||
|
"version": PROMPT_VERSION,
|
||||||
|
"passes": passes,
|
||||||
|
"system": SYSTEM_PROMPT,
|
||||||
|
"rows": [_candidate_for_prompt(row) for row in rows],
|
||||||
|
}
|
||||||
|
encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_decisions(
|
||||||
|
payload: Any,
|
||||||
|
expected_uids: Sequence[str],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
|
||||||
|
raise ValueError("Response must be a JSON object containing an items list")
|
||||||
|
expected = set(expected_uids)
|
||||||
|
actual: dict[str, dict[str, str]] = {}
|
||||||
|
for item in payload["items"]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError("Every response item must be an object")
|
||||||
|
uid = item.get("uid")
|
||||||
|
label = item.get("label")
|
||||||
|
confidence = item.get("confidence")
|
||||||
|
rationale = item.get("rationale")
|
||||||
|
if uid not in expected or uid in actual:
|
||||||
|
raise ValueError(f"Unexpected or duplicate uid in response: {uid!r}")
|
||||||
|
if label not in ALLOWED_LABELS:
|
||||||
|
raise ValueError(f"Invalid label for {uid}: {label!r}")
|
||||||
|
if confidence not in ALLOWED_CONFIDENCE:
|
||||||
|
raise ValueError(f"Invalid confidence for {uid}: {confidence!r}")
|
||||||
|
if not isinstance(rationale, str) or not rationale.strip():
|
||||||
|
raise ValueError(f"Missing rationale for {uid}")
|
||||||
|
actual[uid] = {
|
||||||
|
"uid": uid,
|
||||||
|
"label": label,
|
||||||
|
"confidence": confidence,
|
||||||
|
"rationale": rationale.strip(),
|
||||||
|
}
|
||||||
|
if set(actual) != expected:
|
||||||
|
raise ValueError(f"Response uid mismatch; missing={sorted(expected-set(actual))}")
|
||||||
|
return [actual[uid] for uid in expected_uids]
|
||||||
|
|
||||||
|
|
||||||
|
def _post_chat_completion(
|
||||||
|
*,
|
||||||
|
api_key: str,
|
||||||
|
base_url: str,
|
||||||
|
model: str,
|
||||||
|
user_prompt: str,
|
||||||
|
timeout_seconds: float,
|
||||||
|
max_retries: int,
|
||||||
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
endpoint = base_url.rstrip("/") + "/chat/completions"
|
||||||
|
body = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
"thinking": {"type": "disabled"},
|
||||||
|
"temperature": 0,
|
||||||
|
"max_tokens": 2048,
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
request = urllib.request.Request(
|
||||||
|
endpoint,
|
||||||
|
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
|
||||||
|
envelope = json.loads(response.read().decode("utf-8"))
|
||||||
|
content = envelope["choices"][0]["message"]["content"]
|
||||||
|
if not isinstance(content, str) or not content.strip():
|
||||||
|
raise ValueError("DeepSeek returned empty JSON content")
|
||||||
|
return json.loads(content), envelope.get("usage", {})
|
||||||
|
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, KeyError, json.JSONDecodeError, ValueError) as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt >= max_retries:
|
||||||
|
break
|
||||||
|
time.sleep(min(30.0, 2.0**attempt))
|
||||||
|
raise RuntimeError(f"DeepSeek request failed after {max_retries + 1} attempts: {last_error}")
|
||||||
|
|
||||||
|
|
||||||
|
def adjudicate_batch(
|
||||||
|
rows: Sequence[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
api_key: str,
|
||||||
|
base_url: str,
|
||||||
|
model: str,
|
||||||
|
passes: int,
|
||||||
|
timeout_seconds: float,
|
||||||
|
max_retries: int,
|
||||||
|
) -> tuple[list[dict[str, Any]], Counter[str]]:
|
||||||
|
expected_uids = [row["uid"] for row in rows]
|
||||||
|
by_uid: dict[str, list[dict[str, str]]] = {uid: [] for uid in expected_uids}
|
||||||
|
usage: Counter[str] = Counter()
|
||||||
|
for pass_index in range(passes):
|
||||||
|
payload, pass_usage = _post_chat_completion(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=base_url,
|
||||||
|
model=model,
|
||||||
|
user_prompt=build_user_prompt(rows, pass_index),
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
max_retries=max_retries,
|
||||||
|
)
|
||||||
|
for key, value in pass_usage.items():
|
||||||
|
if isinstance(value, int):
|
||||||
|
usage[key] += value
|
||||||
|
for decision in _validate_decisions(payload, expected_uids):
|
||||||
|
by_uid[decision["uid"]].append(decision)
|
||||||
|
|
||||||
|
request_prompt_sha256 = prompt_hash(rows, passes)
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for source in rows:
|
||||||
|
decisions = by_uid[source["uid"]]
|
||||||
|
labels = {decision["label"] for decision in decisions}
|
||||||
|
unanimous = len(labels) == 1
|
||||||
|
final_label = decisions[0]["label"] if unanimous else "uncertain"
|
||||||
|
all_high = all(decision["confidence"] == "high" for decision in decisions)
|
||||||
|
expected_label = {
|
||||||
|
"backchannel_candidate": "backchannel",
|
||||||
|
"interruption_candidate": "interruption",
|
||||||
|
}.get(source.get("event_type"))
|
||||||
|
accepted = unanimous and all_high and final_label == expected_label
|
||||||
|
promoted = (
|
||||||
|
f"{final_label}_proxy_llm_consensus" if accepted else None
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"schema_version": OUTPUT_SCHEMA_VERSION,
|
||||||
|
"uid": source["uid"],
|
||||||
|
"session": source.get("session"),
|
||||||
|
"split": source.get("split"),
|
||||||
|
"source_event_type": source.get("event_type"),
|
||||||
|
"model": model,
|
||||||
|
"prompt_version": PROMPT_VERSION,
|
||||||
|
"request_prompt_sha256": request_prompt_sha256,
|
||||||
|
"passes": passes,
|
||||||
|
"decisions": decisions,
|
||||||
|
"unanimous": unanimous,
|
||||||
|
"final_label": final_label,
|
||||||
|
"all_high_confidence": all_high,
|
||||||
|
"accepted_for_auxiliary": accepted,
|
||||||
|
"promoted_event_type": promoted,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results, usage
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_resume_rows(
|
||||||
|
rows: Sequence[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
passes: int,
|
||||||
|
) -> None:
|
||||||
|
"""Prevent an append-only output from silently mixing experiments."""
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
for row in rows:
|
||||||
|
uid = row["uid"]
|
||||||
|
if uid in seen:
|
||||||
|
raise ValueError(f"Duplicate uid in existing output: {uid}")
|
||||||
|
seen.add(uid)
|
||||||
|
expected = {
|
||||||
|
"schema_version": OUTPUT_SCHEMA_VERSION,
|
||||||
|
"model": model,
|
||||||
|
"prompt_version": PROMPT_VERSION,
|
||||||
|
"passes": passes,
|
||||||
|
}
|
||||||
|
mismatches = {
|
||||||
|
key: (row.get(key), value)
|
||||||
|
for key, value in expected.items()
|
||||||
|
if row.get(key) != value
|
||||||
|
}
|
||||||
|
if mismatches:
|
||||||
|
raise ValueError(
|
||||||
|
f"Existing output {uid} was produced by a different adjudication "
|
||||||
|
f"configuration: {mismatches}. Use a new --output path."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _chunks(rows: Sequence[dict[str, Any]], size: int) -> Iterable[Sequence[dict[str, Any]]]:
|
||||||
|
for start in range(0, len(rows), size):
|
||||||
|
yield rows[start : start + size]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--input-dir", type=Path, required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
parser.add_argument("--splits", nargs="+", default=["train"])
|
||||||
|
parser.add_argument("--event-types", nargs="+", default=list(DEFAULT_EVENT_TYPES))
|
||||||
|
parser.add_argument("--model", default=os.environ.get("DEEPSEEK_MODEL", DEFAULT_MODEL))
|
||||||
|
parser.add_argument("--base-url", default=os.environ.get("DEEPSEEK_BASE_URL", DEFAULT_BASE_URL))
|
||||||
|
parser.add_argument("--batch-size", type=int, default=8)
|
||||||
|
parser.add_argument("--passes", type=int, default=2)
|
||||||
|
parser.add_argument("--max-events", type=int)
|
||||||
|
parser.add_argument("--selection-seed", type=int, default=2027)
|
||||||
|
parser.add_argument("--timeout-seconds", type=float, default=120.0)
|
||||||
|
parser.add_argument("--max-retries", type=int, default=3)
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
parser.add_argument("--allow-external-transcript-upload", action="store_true")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = parse_args()
|
||||||
|
if args.batch_size <= 0 or args.passes <= 0 or args.max_retries < 0:
|
||||||
|
raise ValueError("batch-size/passes must be positive and max-retries non-negative")
|
||||||
|
candidates = load_candidates(
|
||||||
|
args.input_dir.resolve(),
|
||||||
|
splits=set(args.splits),
|
||||||
|
event_types=set(args.event_types),
|
||||||
|
)
|
||||||
|
candidates = stratified_limit(
|
||||||
|
candidates,
|
||||||
|
args.max_events,
|
||||||
|
seed=args.selection_seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
completed: set[str] = set()
|
||||||
|
if args.output.exists():
|
||||||
|
resume_rows = _load_jsonl(args.output)
|
||||||
|
_validate_resume_rows(resume_rows, model=args.model, passes=args.passes)
|
||||||
|
completed = {row["uid"] for row in resume_rows}
|
||||||
|
pending = [row for row in candidates if row["uid"] not in completed]
|
||||||
|
print(
|
||||||
|
f"Candidates={len(candidates)} completed={len(completed & {row['uid'] for row in candidates})} "
|
||||||
|
f"pending={len(pending)} model={args.model} passes={args.passes}"
|
||||||
|
)
|
||||||
|
if not pending:
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
sample = pending[: args.batch_size]
|
||||||
|
print("DRY RUN: no API request will be made. First request prompt:\n")
|
||||||
|
print(build_user_prompt(sample, 0))
|
||||||
|
print("\nprompt_sha256:", prompt_hash(sample, args.passes))
|
||||||
|
return
|
||||||
|
if not args.allow_external_transcript_upload:
|
||||||
|
raise PermissionError(
|
||||||
|
"Refusing to upload transcript excerpts. Review --dry-run, then pass "
|
||||||
|
"--allow-external-transcript-upload if authorized."
|
||||||
|
)
|
||||||
|
api_key = os.environ.get("DEEPSEEK_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise RuntimeError("Set DEEPSEEK_API_KEY in the environment")
|
||||||
|
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
usage: Counter[str] = Counter()
|
||||||
|
accepted: Counter[str] = Counter()
|
||||||
|
processed = 0
|
||||||
|
with args.output.open("a", encoding="utf-8") as handle:
|
||||||
|
for batch_number, batch in enumerate(_chunks(pending, args.batch_size), start=1):
|
||||||
|
results, batch_usage = adjudicate_batch(
|
||||||
|
batch,
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=args.base_url,
|
||||||
|
model=args.model,
|
||||||
|
passes=args.passes,
|
||||||
|
timeout_seconds=args.timeout_seconds,
|
||||||
|
max_retries=args.max_retries,
|
||||||
|
)
|
||||||
|
usage.update(batch_usage)
|
||||||
|
for result in results:
|
||||||
|
handle.write(json.dumps(result, ensure_ascii=False) + "\n")
|
||||||
|
if result["accepted_for_auxiliary"]:
|
||||||
|
accepted[result["final_label"]] += 1
|
||||||
|
handle.flush()
|
||||||
|
processed += len(results)
|
||||||
|
print(
|
||||||
|
f"batch={batch_number} processed={processed}/{len(pending)} "
|
||||||
|
f"accepted={dict(accepted)} total_tokens={usage.get('total_tokens', 0)}"
|
||||||
|
)
|
||||||
|
print(f"Saved adjudications to {args.output.resolve()}")
|
||||||
|
print("Usage:", dict(usage))
|
||||||
|
print("Accepted high-confidence consensus:", dict(accepted))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
874
derive_easycom_interaction_events.py
Normal file
874
derive_easycom_interaction_events.py
Normal file
@@ -0,0 +1,874 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Derive auditable EasyCom interaction-event proxies from transcripts.
|
||||||
|
|
||||||
|
The released EasyCom transcription JSON files provide participant-resolved
|
||||||
|
``Start_Frame``/``End_Frame`` intervals, text, and ``Target_of_Speech``. They
|
||||||
|
do *not* provide a semantic backchannel label. This script therefore keeps
|
||||||
|
deterministic timing events separate from high-precision weak proxies:
|
||||||
|
|
||||||
|
* ordinary speaker switch;
|
||||||
|
* backchannel proxy (short feedback while another participant retains floor);
|
||||||
|
* interruption proxy (overlapping onset followed by floor takeover);
|
||||||
|
* overlap without takeover;
|
||||||
|
* non-verbal feedback; and
|
||||||
|
* ambiguous candidates, which should be masked from auxiliary training.
|
||||||
|
|
||||||
|
The source dataset is never modified. With ``--output-dir`` the script writes
|
||||||
|
JSONL/CSV reports and optional 5 Hz ``.npz`` onset arrays to a separate path.
|
||||||
|
Without it, the command is a read-only audit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import ast
|
||||||
|
import struct
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
from easycom_manifest import ALL_SESSIONS, TEST_SESSIONS, parse_timecode, probe_video
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE_FPS = 20
|
||||||
|
TASK_FPS = 5
|
||||||
|
CAMERA_WEARER_ID = 2
|
||||||
|
FRAMES_PER_TASK_STEP = SOURCE_FPS // TASK_FPS
|
||||||
|
|
||||||
|
AUXILIARY_EVENT_NAMES = (
|
||||||
|
"ordinary_switch",
|
||||||
|
"backchannel_proxy_high",
|
||||||
|
"overlap_takeover",
|
||||||
|
"interruption_proxy_high",
|
||||||
|
"overlap_hold",
|
||||||
|
"nonverbal_feedback",
|
||||||
|
)
|
||||||
|
AMBIGUOUS_EVENT_NAMES = (
|
||||||
|
"backchannel_candidate",
|
||||||
|
"interruption_candidate",
|
||||||
|
"nonverbal_candidate",
|
||||||
|
)
|
||||||
|
|
||||||
|
MARKER_RE = re.compile(r"\[[A-Za-z]\]")
|
||||||
|
WORD_RE = re.compile(r"[a-z]+(?:'[a-z]+)?")
|
||||||
|
TRAILING_CUTOFF_RE = re.compile(r"-\s*(?:\[[A-Za-z]\]\s*)*$")
|
||||||
|
PURE_NONVERBAL_RE = re.compile(r"^(?:\s*\[(?:L|C|T|H|U)\]\s*[.!?]?\s*)+$")
|
||||||
|
|
||||||
|
# Deliberately conservative. A lexical match is not sufficient: timing must
|
||||||
|
# also show that another participant retained the floor.
|
||||||
|
FEEDBACK_PHRASES = {
|
||||||
|
"alright",
|
||||||
|
"awesome",
|
||||||
|
"cool",
|
||||||
|
"definitely",
|
||||||
|
"exactly",
|
||||||
|
"fair",
|
||||||
|
"good",
|
||||||
|
"got it",
|
||||||
|
"great",
|
||||||
|
"huh",
|
||||||
|
"i know",
|
||||||
|
"i see",
|
||||||
|
"interesting",
|
||||||
|
"makes sense",
|
||||||
|
"mhm",
|
||||||
|
"mm",
|
||||||
|
"mm hmm",
|
||||||
|
"nice",
|
||||||
|
"no",
|
||||||
|
"no way",
|
||||||
|
"nope",
|
||||||
|
"oh",
|
||||||
|
"oh man",
|
||||||
|
"okay",
|
||||||
|
"really",
|
||||||
|
"right",
|
||||||
|
"sure",
|
||||||
|
"true",
|
||||||
|
"uh huh",
|
||||||
|
"uh-huh",
|
||||||
|
"wow",
|
||||||
|
"yeah",
|
||||||
|
"yep",
|
||||||
|
"yes",
|
||||||
|
"yup",
|
||||||
|
}
|
||||||
|
FEEDBACK_TOKENS = {
|
||||||
|
token for phrase in FEEDBACK_PHRASES for token in WORD_RE.findall(phrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Clip:
|
||||||
|
path: str
|
||||||
|
transcript_path: str
|
||||||
|
stem: str
|
||||||
|
source_start_seconds: float
|
||||||
|
source_end_seconds: float
|
||||||
|
dense_start_frame: int
|
||||||
|
num_frames: int
|
||||||
|
segment_id: int
|
||||||
|
discontinuity_before_seconds: float | None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dense_start_seconds(self) -> float:
|
||||||
|
return self.dense_start_frame / SOURCE_FPS
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Utterance:
|
||||||
|
uid: str
|
||||||
|
session: str
|
||||||
|
clip_stem: str
|
||||||
|
clip_index: int
|
||||||
|
segment_id: int
|
||||||
|
participant_id: int
|
||||||
|
target_of_speech: tuple[int | str, ...]
|
||||||
|
transcription: str
|
||||||
|
source_start_frame: int
|
||||||
|
source_end_frame: int
|
||||||
|
dense_start_frame: int
|
||||||
|
dense_end_frame_exclusive: int
|
||||||
|
dense_start_seconds: float
|
||||||
|
dense_end_seconds: float
|
||||||
|
duration_seconds: float
|
||||||
|
starts_at_clip_boundary: bool
|
||||||
|
ends_at_clip_boundary: bool
|
||||||
|
annotation_end_frame_clamped_by: int
|
||||||
|
is_cross_clip_continuation: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Event:
|
||||||
|
uid: str
|
||||||
|
session: str
|
||||||
|
split: str
|
||||||
|
event_type: str
|
||||||
|
confidence: str
|
||||||
|
trainable_auxiliary: bool
|
||||||
|
participant_id: int
|
||||||
|
participant_role: str
|
||||||
|
previous_floor_participant_id: int | None
|
||||||
|
retained_floor_participant_id: int | None
|
||||||
|
target_matches_previous_floor: bool
|
||||||
|
clip_stem: str
|
||||||
|
source_start_frame: int
|
||||||
|
dense_start_seconds: float
|
||||||
|
task_step_index: int
|
||||||
|
duration_seconds: float
|
||||||
|
onset_overlap_seconds: float
|
||||||
|
gap_from_previous_seconds: float | None
|
||||||
|
speaker_after_previous_end_seconds: float
|
||||||
|
previous_transcript_has_cutoff: bool
|
||||||
|
previous_transcription: str | None
|
||||||
|
lexical_feedback: bool
|
||||||
|
pure_nonverbal: bool
|
||||||
|
transcription: str
|
||||||
|
target_of_speech: tuple[int | str, ...]
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Rules:
|
||||||
|
context_seconds: float = 1.5
|
||||||
|
backchannel_max_seconds: float = 1.0
|
||||||
|
max_switch_gap_seconds: float = 0.6
|
||||||
|
min_overlap_seconds: float = 0.1
|
||||||
|
takeover_candidate_seconds: float = 0.4
|
||||||
|
takeover_high_seconds: float = 0.8
|
||||||
|
max_contiguous_source_gap_seconds: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path: Path) -> Any:
|
||||||
|
payload = path.read_bytes()
|
||||||
|
errors: list[str] = []
|
||||||
|
for encoding in ("utf-8", "cp1252", "latin1"):
|
||||||
|
try:
|
||||||
|
return json.loads(payload.decode(encoding))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
errors.append(f"{encoding}: {exc}")
|
||||||
|
raise ValueError(f"Could not decode {path}: {'; '.join(errors)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_words(text: str) -> tuple[str, ...]:
|
||||||
|
return tuple(WORD_RE.findall(MARKER_RE.sub(" ", text).lower()))
|
||||||
|
|
||||||
|
|
||||||
|
def is_lexical_feedback(text: str) -> bool:
|
||||||
|
if "?" in text:
|
||||||
|
return False
|
||||||
|
words = _normalized_words(text)
|
||||||
|
if not words or len(words) > 5:
|
||||||
|
return False
|
||||||
|
phrase = " ".join(words)
|
||||||
|
return phrase in FEEDBACK_PHRASES or all(word in FEEDBACK_TOKENS for word in words)
|
||||||
|
|
||||||
|
|
||||||
|
def is_pure_nonverbal(text: str) -> bool:
|
||||||
|
return PURE_NONVERBAL_RE.fullmatch(text.strip()) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def has_trailing_cutoff(utterance: Utterance) -> bool:
|
||||||
|
# [X] at a one-minute boundary denotes a continued/truncated transcript,
|
||||||
|
# not evidence that another participant interrupted the speaker.
|
||||||
|
return (
|
||||||
|
TRAILING_CUTOFF_RE.search(utterance.transcription.strip()) is not None
|
||||||
|
and "[X]" not in utterance.transcription.upper()
|
||||||
|
and not utterance.ends_at_clip_boundary
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _target_values(value: Any) -> tuple[int | str, ...]:
|
||||||
|
values = value if isinstance(value, list) else [value]
|
||||||
|
normalized: list[int | str] = []
|
||||||
|
for item in values:
|
||||||
|
if isinstance(item, bool):
|
||||||
|
normalized.append(str(item))
|
||||||
|
elif isinstance(item, int):
|
||||||
|
normalized.append(item)
|
||||||
|
elif isinstance(item, float) and item.is_integer():
|
||||||
|
normalized.append(int(item))
|
||||||
|
else:
|
||||||
|
normalized.append(str(item))
|
||||||
|
return tuple(normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def _target_length(path: Path | None) -> int | None:
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
# Read only the NPY header so an audit does not require NumPy on the raw
|
||||||
|
# data host. NumPy is required only when --write-frame-arrays is requested.
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
if handle.read(6) != bytes([147]) + b"NUMPY":
|
||||||
|
raise ValueError(f"Not a NumPy array: {path}")
|
||||||
|
major, _minor = handle.read(2)
|
||||||
|
length_size = 2 if major == 1 else 4
|
||||||
|
header_length = struct.unpack(
|
||||||
|
"<H" if length_size == 2 else "<I",
|
||||||
|
handle.read(length_size),
|
||||||
|
)[0]
|
||||||
|
header = ast.literal_eval(handle.read(header_length).decode("latin1").strip())
|
||||||
|
shape = header.get("shape")
|
||||||
|
if not isinstance(shape, tuple) or len(shape) != 2 or shape[1] != 3:
|
||||||
|
raise ValueError(f"Expected target [T,3], got {shape}: {path}")
|
||||||
|
return int(shape[0])
|
||||||
|
|
||||||
|
|
||||||
|
def build_clips(dataset_root: Path, session: str, rules: Rules) -> list[Clip]:
|
||||||
|
video_dir = dataset_root / "Main" / "Video_Compressed" / session
|
||||||
|
transcript_dir = dataset_root / "Main" / "Speech_Transcriptions" / session
|
||||||
|
if not video_dir.is_dir():
|
||||||
|
raise FileNotFoundError(f"Missing video directory: {video_dir}")
|
||||||
|
if not transcript_dir.is_dir():
|
||||||
|
raise FileNotFoundError(f"Missing transcript directory: {transcript_dir}")
|
||||||
|
|
||||||
|
video_paths = sorted(video_dir.glob("*.mp4"), key=lambda p: parse_timecode(p.stem))
|
||||||
|
transcript_paths = {path.stem: path for path in transcript_dir.glob("*.json")}
|
||||||
|
video_stems = {path.stem for path in video_paths}
|
||||||
|
if video_stems != set(transcript_paths):
|
||||||
|
missing_json = sorted(video_stems - set(transcript_paths))
|
||||||
|
missing_video = sorted(set(transcript_paths) - video_stems)
|
||||||
|
raise ValueError(
|
||||||
|
f"{session} video/transcript inventory differs; "
|
||||||
|
f"missing_json={missing_json}, missing_video={missing_video}"
|
||||||
|
)
|
||||||
|
|
||||||
|
clips: list[Clip] = []
|
||||||
|
dense_start_frame = 0
|
||||||
|
previous_source_end: float | None = None
|
||||||
|
segment_id = 0
|
||||||
|
for path in video_paths:
|
||||||
|
media = probe_video(path)
|
||||||
|
frames = int(media["frames_at_20hz"])
|
||||||
|
source_start = parse_timecode(path.stem)
|
||||||
|
source_end = source_start + frames / SOURCE_FPS
|
||||||
|
delta = None if previous_source_end is None else source_start - previous_source_end
|
||||||
|
if delta is not None and abs(delta) > rules.max_contiguous_source_gap_seconds:
|
||||||
|
segment_id += 1
|
||||||
|
clips.append(
|
||||||
|
Clip(
|
||||||
|
path=str(path.resolve()),
|
||||||
|
transcript_path=str(transcript_paths[path.stem].resolve()),
|
||||||
|
stem=path.stem,
|
||||||
|
source_start_seconds=source_start,
|
||||||
|
source_end_seconds=source_end,
|
||||||
|
dense_start_frame=dense_start_frame,
|
||||||
|
num_frames=frames,
|
||||||
|
segment_id=segment_id,
|
||||||
|
discontinuity_before_seconds=delta,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dense_start_frame += frames
|
||||||
|
previous_source_end = source_end
|
||||||
|
return clips
|
||||||
|
|
||||||
|
|
||||||
|
def load_utterances(session: str, clips: Sequence[Clip]) -> list[Utterance]:
|
||||||
|
utterances: list[Utterance] = []
|
||||||
|
per_clip: list[list[Utterance]] = []
|
||||||
|
for clip_index, clip in enumerate(clips):
|
||||||
|
payload = _load_json(Path(clip.transcript_path))
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise ValueError(f"Expected a list in {clip.transcript_path}")
|
||||||
|
clip_rows: list[Utterance] = []
|
||||||
|
for row_index, row in enumerate(payload):
|
||||||
|
required = {
|
||||||
|
"Start_Frame",
|
||||||
|
"End_Frame",
|
||||||
|
"Participant_ID",
|
||||||
|
"Transcription",
|
||||||
|
"Target_of_Speech",
|
||||||
|
}
|
||||||
|
if not isinstance(row, dict) or not required.issubset(row):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid transcription row {row_index} in {clip.transcript_path}: {row}"
|
||||||
|
)
|
||||||
|
start = int(row["Start_Frame"])
|
||||||
|
annotation_end = int(row["End_Frame"])
|
||||||
|
end_overrun = max(0, annotation_end - clip.num_frames)
|
||||||
|
# A few released MP4/container frame counts differ from the JSON
|
||||||
|
# endpoint by one frame. Permit only a tightly bounded clamp and
|
||||||
|
# expose it in the audit report; larger disagreements remain fatal.
|
||||||
|
if end_overrun > 2:
|
||||||
|
raise ValueError(
|
||||||
|
f"Annotation endpoint exceeds media by {end_overrun} frames in "
|
||||||
|
f"{clip.transcript_path}: End_Frame={annotation_end}, "
|
||||||
|
f"clip_frames={clip.num_frames}"
|
||||||
|
)
|
||||||
|
end = min(annotation_end, clip.num_frames)
|
||||||
|
if start < 1 or end < start:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid inclusive frame interval [{start},{annotation_end}] in "
|
||||||
|
f"{clip.transcript_path}; clip has {clip.num_frames} frames"
|
||||||
|
)
|
||||||
|
dense_start = clip.dense_start_frame + start - 1
|
||||||
|
dense_end = clip.dense_start_frame + end
|
||||||
|
uid = f"{session}/{clip.stem}/{row_index:04d}"
|
||||||
|
clip_rows.append(
|
||||||
|
Utterance(
|
||||||
|
uid=uid,
|
||||||
|
session=session,
|
||||||
|
clip_stem=clip.stem,
|
||||||
|
clip_index=clip_index,
|
||||||
|
segment_id=clip.segment_id,
|
||||||
|
participant_id=int(row["Participant_ID"]),
|
||||||
|
target_of_speech=_target_values(row["Target_of_Speech"]),
|
||||||
|
transcription=str(row["Transcription"]),
|
||||||
|
source_start_frame=start,
|
||||||
|
source_end_frame=end,
|
||||||
|
dense_start_frame=dense_start,
|
||||||
|
dense_end_frame_exclusive=dense_end,
|
||||||
|
dense_start_seconds=dense_start / SOURCE_FPS,
|
||||||
|
dense_end_seconds=dense_end / SOURCE_FPS,
|
||||||
|
duration_seconds=(end - start + 1) / SOURCE_FPS,
|
||||||
|
starts_at_clip_boundary=start <= 2,
|
||||||
|
ends_at_clip_boundary=end >= clip.num_frames - 1,
|
||||||
|
annotation_end_frame_clamped_by=end_overrun,
|
||||||
|
is_cross_clip_continuation=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
per_clip.append(clip_rows)
|
||||||
|
|
||||||
|
# Mark, rather than merge, cross-file continuations. Their timing remains
|
||||||
|
# useful for floor context, but they must not create a new event onset.
|
||||||
|
for clip_index, rows in enumerate(per_clip):
|
||||||
|
previous_rows = per_clip[clip_index - 1] if clip_index > 0 else []
|
||||||
|
contiguous = (
|
||||||
|
clip_index > 0 and clips[clip_index].segment_id == clips[clip_index - 1].segment_id
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
continuation = False
|
||||||
|
if contiguous and row.starts_at_clip_boundary:
|
||||||
|
candidates = [
|
||||||
|
prev
|
||||||
|
for prev in previous_rows
|
||||||
|
if prev.participant_id == row.participant_id
|
||||||
|
and prev.ends_at_clip_boundary
|
||||||
|
]
|
||||||
|
continuation = bool(candidates) and (
|
||||||
|
"[X]" in row.transcription.upper()
|
||||||
|
or any("[X]" in prev.transcription.upper() for prev in candidates)
|
||||||
|
)
|
||||||
|
utterances.append(
|
||||||
|
Utterance(**{**asdict(row), "is_cross_clip_continuation": continuation})
|
||||||
|
)
|
||||||
|
return sorted(utterances, key=lambda u: (u.dense_start_frame, u.dense_end_frame_exclusive, u.uid))
|
||||||
|
|
||||||
|
|
||||||
|
def _overlap_seconds(row: Utterance, start: float, end: float) -> float:
|
||||||
|
return max(0.0, min(row.dense_end_seconds, end) - max(row.dense_start_seconds, start))
|
||||||
|
|
||||||
|
|
||||||
|
def _coverage_by_participant(
|
||||||
|
utterances: Sequence[Utterance],
|
||||||
|
*,
|
||||||
|
segment_id: int,
|
||||||
|
start: float,
|
||||||
|
end: float,
|
||||||
|
exclude_participant: int | None = None,
|
||||||
|
) -> dict[int, float]:
|
||||||
|
intervals: dict[int, list[tuple[float, float]]] = defaultdict(list)
|
||||||
|
for row in utterances:
|
||||||
|
if row.segment_id != segment_id or row.participant_id == exclude_participant:
|
||||||
|
continue
|
||||||
|
left = max(start, row.dense_start_seconds)
|
||||||
|
right = min(end, row.dense_end_seconds)
|
||||||
|
if right > left:
|
||||||
|
intervals[row.participant_id].append((left, right))
|
||||||
|
coverage: dict[int, float] = {}
|
||||||
|
for participant, spans in intervals.items():
|
||||||
|
spans.sort()
|
||||||
|
total = 0.0
|
||||||
|
left, right = spans[0]
|
||||||
|
for next_left, next_right in spans[1:]:
|
||||||
|
if next_left <= right:
|
||||||
|
right = max(right, next_right)
|
||||||
|
else:
|
||||||
|
total += right - left
|
||||||
|
left, right = next_left, next_right
|
||||||
|
coverage[participant] = total + right - left
|
||||||
|
return coverage
|
||||||
|
|
||||||
|
|
||||||
|
def _dominant(coverage: dict[int, float]) -> int | None:
|
||||||
|
if not coverage:
|
||||||
|
return None
|
||||||
|
participant, seconds = max(coverage.items(), key=lambda item: (item[1], -item[0]))
|
||||||
|
return participant if seconds > 1e-9 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _previous_floor(
|
||||||
|
row: Utterance,
|
||||||
|
utterances: Sequence[Utterance],
|
||||||
|
rules: Rules,
|
||||||
|
) -> tuple[int | None, Utterance | None, float | None, float]:
|
||||||
|
candidates = [
|
||||||
|
other
|
||||||
|
for other in utterances
|
||||||
|
if other.segment_id == row.segment_id
|
||||||
|
and other.participant_id != row.participant_id
|
||||||
|
and other.dense_start_seconds < row.dense_start_seconds
|
||||||
|
and other.dense_end_seconds >= row.dense_start_seconds - rules.max_switch_gap_seconds
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
return None, None, None, 0.0
|
||||||
|
active = [other for other in candidates if other.dense_end_seconds > row.dense_start_seconds]
|
||||||
|
previous = max(active or candidates, key=lambda other: other.dense_end_seconds)
|
||||||
|
gap = row.dense_start_seconds - previous.dense_end_seconds
|
||||||
|
overlap = max(0.0, -gap)
|
||||||
|
return previous.participant_id, previous, gap, overlap
|
||||||
|
|
||||||
|
|
||||||
|
def classify_utterance(
|
||||||
|
row: Utterance,
|
||||||
|
utterances: Sequence[Utterance],
|
||||||
|
*,
|
||||||
|
camera_wearer_id: int,
|
||||||
|
rules: Rules,
|
||||||
|
) -> Event:
|
||||||
|
split = "test" if row.session in TEST_SESSIONS else "train"
|
||||||
|
role = "wearer" if row.participant_id == camera_wearer_id else "other"
|
||||||
|
lexical = is_lexical_feedback(row.transcription)
|
||||||
|
nonverbal = is_pure_nonverbal(row.transcription)
|
||||||
|
previous_pid, previous, gap, onset_overlap = _previous_floor(row, utterances, rules)
|
||||||
|
|
||||||
|
pre_coverage = _coverage_by_participant(
|
||||||
|
utterances,
|
||||||
|
segment_id=row.segment_id,
|
||||||
|
start=row.dense_start_seconds - rules.context_seconds,
|
||||||
|
end=row.dense_start_seconds,
|
||||||
|
exclude_participant=row.participant_id,
|
||||||
|
)
|
||||||
|
post_coverage = _coverage_by_participant(
|
||||||
|
utterances,
|
||||||
|
segment_id=row.segment_id,
|
||||||
|
start=row.dense_end_seconds,
|
||||||
|
end=row.dense_end_seconds + rules.context_seconds,
|
||||||
|
exclude_participant=row.participant_id,
|
||||||
|
)
|
||||||
|
pre_pid = _dominant(pre_coverage) or previous_pid
|
||||||
|
post_pid = _dominant(post_coverage)
|
||||||
|
retained_pid = pre_pid if pre_pid is not None and pre_pid == post_pid else None
|
||||||
|
|
||||||
|
old_end = previous.dense_end_seconds if previous is not None else row.dense_start_seconds
|
||||||
|
speaker_after_old = max(0.0, row.dense_end_seconds - max(row.dense_start_seconds, old_end))
|
||||||
|
previous_cutoff = has_trailing_cutoff(previous) if previous is not None else False
|
||||||
|
target_matches = previous_pid is not None and previous_pid in row.target_of_speech
|
||||||
|
short = row.duration_seconds <= rules.backchannel_max_seconds + 1e-9
|
||||||
|
overlaps = onset_overlap >= rules.min_overlap_seconds - 1e-9
|
||||||
|
takeover_candidate = speaker_after_old >= rules.takeover_candidate_seconds - 1e-9
|
||||||
|
takeover_high = speaker_after_old >= rules.takeover_high_seconds - 1e-9
|
||||||
|
|
||||||
|
event_type = "unclassified"
|
||||||
|
confidence = "none"
|
||||||
|
trainable = False
|
||||||
|
reason = "no other-speaker floor relation meeting a proxy rule"
|
||||||
|
|
||||||
|
laughter_feedback = nonverbal and "[L]" in row.transcription.upper()
|
||||||
|
|
||||||
|
if row.is_cross_clip_continuation:
|
||||||
|
event_type = "cross_clip_continuation"
|
||||||
|
reason = "[X] continuation at a contiguous one-minute file boundary"
|
||||||
|
elif laughter_feedback and (retained_pid is not None or overlaps):
|
||||||
|
event_type = "nonverbal_feedback"
|
||||||
|
confidence = "high"
|
||||||
|
trainable = True
|
||||||
|
reason = "laughter-only marker during/around another participant's retained floor"
|
||||||
|
elif nonverbal and (retained_pid is not None or overlaps):
|
||||||
|
event_type = "nonverbal_candidate"
|
||||||
|
confidence = "ambiguous"
|
||||||
|
reason = "pure hesitation/cough/throat-clear/unintelligible marker; feedback function is unknown"
|
||||||
|
elif short and retained_pid is not None:
|
||||||
|
if lexical:
|
||||||
|
event_type = "backchannel_proxy_high"
|
||||||
|
confidence = "high"
|
||||||
|
trainable = True
|
||||||
|
reason = "short lexical feedback; the same other participant dominates before and after"
|
||||||
|
else:
|
||||||
|
event_type = "backchannel_candidate"
|
||||||
|
confidence = "ambiguous"
|
||||||
|
reason = "short contribution while another participant retains floor, but no conservative lexical cue"
|
||||||
|
elif previous is not None and (overlaps or (previous_cutoff and gap is not None and gap <= 0.15)):
|
||||||
|
if previous_cutoff and takeover_candidate:
|
||||||
|
event_type = "interruption_proxy_high"
|
||||||
|
confidence = "high"
|
||||||
|
trainable = True
|
||||||
|
reason = "previous transcript is cut off and the incoming speaker continues after that floor ends"
|
||||||
|
elif takeover_high:
|
||||||
|
event_type = "overlap_takeover"
|
||||||
|
confidence = "timing"
|
||||||
|
trainable = True
|
||||||
|
reason = "overlapping onset followed by sustained takeover, without semantic interruption evidence"
|
||||||
|
elif takeover_candidate:
|
||||||
|
event_type = "interruption_candidate"
|
||||||
|
confidence = "ambiguous"
|
||||||
|
reason = "overlapping onset followed by a possible short takeover; interruption intent is unknown"
|
||||||
|
else:
|
||||||
|
event_type = "overlap_hold"
|
||||||
|
confidence = "timing"
|
||||||
|
trainable = True
|
||||||
|
reason = "overlap without enough evidence that the incoming speaker took the floor"
|
||||||
|
elif (
|
||||||
|
previous is not None
|
||||||
|
and gap is not None
|
||||||
|
and 0.0 <= gap <= rules.max_switch_gap_seconds
|
||||||
|
and row.duration_seconds >= rules.takeover_high_seconds
|
||||||
|
):
|
||||||
|
event_type = "ordinary_switch"
|
||||||
|
confidence = "timing"
|
||||||
|
trainable = True
|
||||||
|
reason = "other speaker ended recently and the incoming speaker sustained a new turn"
|
||||||
|
|
||||||
|
return Event(
|
||||||
|
uid=row.uid,
|
||||||
|
session=row.session,
|
||||||
|
split=split,
|
||||||
|
event_type=event_type,
|
||||||
|
confidence=confidence,
|
||||||
|
trainable_auxiliary=trainable,
|
||||||
|
participant_id=row.participant_id,
|
||||||
|
participant_role=role,
|
||||||
|
previous_floor_participant_id=previous_pid,
|
||||||
|
retained_floor_participant_id=retained_pid,
|
||||||
|
target_matches_previous_floor=target_matches,
|
||||||
|
clip_stem=row.clip_stem,
|
||||||
|
source_start_frame=row.source_start_frame,
|
||||||
|
dense_start_seconds=row.dense_start_seconds,
|
||||||
|
task_step_index=row.dense_start_frame // FRAMES_PER_TASK_STEP,
|
||||||
|
duration_seconds=row.duration_seconds,
|
||||||
|
onset_overlap_seconds=onset_overlap,
|
||||||
|
gap_from_previous_seconds=gap,
|
||||||
|
speaker_after_previous_end_seconds=speaker_after_old,
|
||||||
|
previous_transcript_has_cutoff=previous_cutoff,
|
||||||
|
previous_transcription=None if previous is None else previous.transcription,
|
||||||
|
lexical_feedback=lexical,
|
||||||
|
pure_nonverbal=nonverbal,
|
||||||
|
transcription=row.transcription,
|
||||||
|
target_of_speech=row.target_of_speech,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_event(event: Event) -> dict[str, Any]:
|
||||||
|
payload = asdict(event)
|
||||||
|
payload["target_of_speech"] = list(event.target_of_speech)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _write_session(
|
||||||
|
output_dir: Path,
|
||||||
|
session: str,
|
||||||
|
events: Sequence[Event],
|
||||||
|
*,
|
||||||
|
target_steps: int | None,
|
||||||
|
write_frame_arrays: bool,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> None:
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
rows = [_serialize_event(event) for event in events if event.event_type != "unclassified"]
|
||||||
|
jsonl_path = output_dir / f"{session}.events.jsonl"
|
||||||
|
csv_path = output_dir / f"{session}.events.csv"
|
||||||
|
for path in (jsonl_path, csv_path):
|
||||||
|
if path.exists() and not overwrite:
|
||||||
|
raise FileExistsError(f"Refusing to overwrite {path}; pass --overwrite")
|
||||||
|
with jsonl_path.open("w", encoding="utf-8") as handle:
|
||||||
|
for row in rows:
|
||||||
|
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||||
|
fieldnames = list(rows[0]) if rows else [field.name for field in Event.__dataclass_fields__.values()]
|
||||||
|
with csv_path.open("w", encoding="utf-8", newline="") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
for row in rows:
|
||||||
|
row = dict(row)
|
||||||
|
row["target_of_speech"] = json.dumps(row["target_of_speech"], ensure_ascii=False)
|
||||||
|
writer.writerow(row)
|
||||||
|
|
||||||
|
if write_frame_arrays:
|
||||||
|
if target_steps is None:
|
||||||
|
raise ValueError("--write-frame-arrays requires --target-root")
|
||||||
|
npz_path = output_dir / f"{session}.aux_targets.npz"
|
||||||
|
if npz_path.exists() and not overwrite:
|
||||||
|
raise FileExistsError(f"Refusing to overwrite {npz_path}; pass --overwrite")
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("numpy is required to write aligned auxiliary arrays") from exc
|
||||||
|
onset = np.zeros((target_steps, len(AUXILIARY_EVENT_NAMES)), dtype=np.uint8)
|
||||||
|
role = np.zeros_like(onset, dtype=np.int8)
|
||||||
|
collisions = np.zeros_like(onset, dtype=np.uint8)
|
||||||
|
event_to_column = {name: index for index, name in enumerate(AUXILIARY_EVENT_NAMES)}
|
||||||
|
for event in events:
|
||||||
|
column = event_to_column.get(event.event_type)
|
||||||
|
step = event.task_step_index
|
||||||
|
if column is None or not event.trainable_auxiliary or not 0 <= step < target_steps:
|
||||||
|
continue
|
||||||
|
encoded_role = 1 if event.participant_role == "wearer" else 2
|
||||||
|
if onset[step, column]:
|
||||||
|
collisions[step, column] = 1
|
||||||
|
if role[step, column] != encoded_role:
|
||||||
|
role[step, column] = -1
|
||||||
|
else:
|
||||||
|
onset[step, column] = 1
|
||||||
|
role[step, column] = encoded_role
|
||||||
|
np.savez_compressed(
|
||||||
|
npz_path,
|
||||||
|
event_onsets=onset,
|
||||||
|
event_roles=role,
|
||||||
|
collisions=collisions,
|
||||||
|
event_names=np.asarray(AUXILIARY_EVENT_NAMES),
|
||||||
|
task_fps=np.asarray(TASK_FPS),
|
||||||
|
target_steps=np.asarray(target_steps),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session_summary(
|
||||||
|
session: str,
|
||||||
|
clips: Sequence[Clip],
|
||||||
|
utterances: Sequence[Utterance],
|
||||||
|
events: Sequence[Event],
|
||||||
|
target_steps: int | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
counts = Counter(event.event_type for event in events)
|
||||||
|
trainable = Counter(
|
||||||
|
event.event_type for event in events if event.trainable_auxiliary
|
||||||
|
)
|
||||||
|
dense_frames = sum(clip.num_frames for clip in clips)
|
||||||
|
expected_steps = dense_frames // FRAMES_PER_TASK_STEP
|
||||||
|
return {
|
||||||
|
"session": session,
|
||||||
|
"split": "test" if session in TEST_SESSIONS else "train",
|
||||||
|
"clips": len(clips),
|
||||||
|
"source_discontinuities": max((clip.segment_id for clip in clips), default=0),
|
||||||
|
"utterances": len(utterances),
|
||||||
|
"annotation_endpoints_clamped": sum(
|
||||||
|
row.annotation_end_frame_clamped_by > 0 for row in utterances
|
||||||
|
),
|
||||||
|
"annotation_frames_clamped_total": sum(
|
||||||
|
row.annotation_end_frame_clamped_by for row in utterances
|
||||||
|
),
|
||||||
|
"dense_frames": dense_frames,
|
||||||
|
"expected_5hz_steps": expected_steps,
|
||||||
|
"target_steps": target_steps,
|
||||||
|
"target_minus_expected_steps": (
|
||||||
|
None if target_steps is None else target_steps - expected_steps
|
||||||
|
),
|
||||||
|
"event_counts": dict(sorted(counts.items())),
|
||||||
|
"trainable_auxiliary_counts": dict(sorted(trainable.items())),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--dataset-root", type=Path, required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--target-root",
|
||||||
|
type=Path,
|
||||||
|
help="Optional EgoSpeak target_perframe directory for exact 5 Hz length/aligned .npz output",
|
||||||
|
)
|
||||||
|
parser.add_argument("--output-dir", type=Path, help="Omit for a read-only audit")
|
||||||
|
parser.add_argument(
|
||||||
|
"--write-frame-arrays",
|
||||||
|
action="store_true",
|
||||||
|
help="Write aligned 5 Hz .npz arrays; requires --target-root and numpy",
|
||||||
|
)
|
||||||
|
parser.add_argument("--sessions", nargs="+", default=list(ALL_SESSIONS))
|
||||||
|
parser.add_argument("--camera-wearer-id", type=int, default=CAMERA_WEARER_ID)
|
||||||
|
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)
|
||||||
|
parser.add_argument("--examples-per-event", type=int, default=3)
|
||||||
|
parser.add_argument("--overwrite", action="store_true")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = parse_args()
|
||||||
|
if SOURCE_FPS % TASK_FPS:
|
||||||
|
raise RuntimeError("source/task FPS ratio must be integral")
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
for name, value in asdict(rules).items():
|
||||||
|
if value < 0:
|
||||||
|
raise ValueError(f"{name} must be non-negative")
|
||||||
|
unknown = sorted(set(args.sessions) - set(ALL_SESSIONS))
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"Unknown EasyCom sessions: {unknown}")
|
||||||
|
if args.write_frame_arrays and args.output_dir is None:
|
||||||
|
raise ValueError("--write-frame-arrays requires --output-dir")
|
||||||
|
if args.write_frame_arrays and args.target_root is None:
|
||||||
|
raise ValueError("--write-frame-arrays requires --target-root")
|
||||||
|
|
||||||
|
all_events: list[Event] = []
|
||||||
|
summaries: list[dict[str, Any]] = []
|
||||||
|
for session in args.sessions:
|
||||||
|
clips = build_clips(args.dataset_root.resolve(), session, rules)
|
||||||
|
utterances = load_utterances(session, clips)
|
||||||
|
events = [
|
||||||
|
classify_utterance(
|
||||||
|
row,
|
||||||
|
utterances,
|
||||||
|
camera_wearer_id=args.camera_wearer_id,
|
||||||
|
rules=rules,
|
||||||
|
)
|
||||||
|
for row in utterances
|
||||||
|
]
|
||||||
|
target_path = (
|
||||||
|
None
|
||||||
|
if args.target_root is None
|
||||||
|
else args.target_root.resolve() / f"{session}.npy"
|
||||||
|
)
|
||||||
|
if target_path is not None and not target_path.is_file():
|
||||||
|
raise FileNotFoundError(f"Missing target: {target_path}")
|
||||||
|
target_steps = _target_length(target_path)
|
||||||
|
summary = _session_summary(session, clips, utterances, events, target_steps)
|
||||||
|
if summary["target_minus_expected_steps"] not in (None, 0):
|
||||||
|
raise ValueError(
|
||||||
|
f"{session} target/media mismatch: {summary['target_minus_expected_steps']:+d} steps"
|
||||||
|
)
|
||||||
|
summaries.append(summary)
|
||||||
|
all_events.extend(events)
|
||||||
|
if args.output_dir is not None:
|
||||||
|
_write_session(
|
||||||
|
args.output_dir.resolve(),
|
||||||
|
session,
|
||||||
|
events,
|
||||||
|
target_steps=target_steps,
|
||||||
|
write_frame_arrays=args.write_frame_arrays,
|
||||||
|
overwrite=args.overwrite,
|
||||||
|
)
|
||||||
|
counts = Counter(event.event_type for event in events)
|
||||||
|
print(
|
||||||
|
f"{session}: utterances={len(utterances)} "
|
||||||
|
+ " ".join(f"{name}={count}" for name, count in sorted(counts.items()))
|
||||||
|
)
|
||||||
|
|
||||||
|
aggregate = Counter(event.event_type for event in all_events)
|
||||||
|
trainable = Counter(
|
||||||
|
event.event_type for event in all_events if event.trainable_auxiliary
|
||||||
|
)
|
||||||
|
report = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"dataset": "EasyCom",
|
||||||
|
"dataset_root": str(args.dataset_root.resolve()),
|
||||||
|
"target_root": None if args.target_root is None else str(args.target_root.resolve()),
|
||||||
|
"source_fps": SOURCE_FPS,
|
||||||
|
"task_fps": TASK_FPS,
|
||||||
|
"camera_wearer_participant_id": args.camera_wearer_id,
|
||||||
|
"rules": asdict(rules),
|
||||||
|
"auxiliary_event_names": list(AUXILIARY_EVENT_NAMES),
|
||||||
|
"ambiguous_event_names": list(AMBIGUOUS_EVENT_NAMES),
|
||||||
|
"semantic_warning": (
|
||||||
|
"Backchannel and interruption are transcript/timing-derived weak proxies, "
|
||||||
|
"not released EasyCom ground-truth semantic annotations."
|
||||||
|
),
|
||||||
|
"recommended_training_policy": (
|
||||||
|
"Use only trainable_auxiliary events for auxiliary loss; mask candidate and "
|
||||||
|
"unclassified rows. Keep EgoSpeak's original three-label evaluator unchanged."
|
||||||
|
),
|
||||||
|
"event_counts": dict(sorted(aggregate.items())),
|
||||||
|
"trainable_auxiliary_counts": dict(sorted(trainable.items())),
|
||||||
|
"sessions": summaries,
|
||||||
|
"examples": {},
|
||||||
|
}
|
||||||
|
for event_type in sorted(aggregate):
|
||||||
|
report["examples"][event_type] = [
|
||||||
|
_serialize_event(event)
|
||||||
|
for event in all_events
|
||||||
|
if event.event_type == event_type
|
||||||
|
][: args.examples_per_event]
|
||||||
|
|
||||||
|
print("\nAggregate event counts")
|
||||||
|
for name, count in sorted(aggregate.items()):
|
||||||
|
print(f" {name:<28} {count:>6}")
|
||||||
|
print("Trainable auxiliary counts")
|
||||||
|
for name, count in sorted(trainable.items()):
|
||||||
|
print(f" {name:<28} {count:>6}")
|
||||||
|
if args.examples_per_event:
|
||||||
|
print("Examples")
|
||||||
|
for event_type in sorted(aggregate):
|
||||||
|
examples = [
|
||||||
|
event for event in all_events if event.event_type == event_type
|
||||||
|
][: args.examples_per_event]
|
||||||
|
for event in examples:
|
||||||
|
text = " ".join(event.transcription.split())
|
||||||
|
if len(text) > 100:
|
||||||
|
text = text[:97] + "..."
|
||||||
|
print(
|
||||||
|
f" {event_type:<28} {event.session} "
|
||||||
|
f"t={event.dense_start_seconds:.2f}s p={event.participant_id}: {text}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"\nWARNING: backchannel/interruption outputs are weak proxies; "
|
||||||
|
"ambiguous candidates are intentionally masked."
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.output_dir is not None:
|
||||||
|
output_dir = args.output_dir.resolve()
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
report_path = output_dir / "summary.json"
|
||||||
|
if report_path.exists() and not args.overwrite:
|
||||||
|
raise FileExistsError(f"Refusing to overwrite {report_path}; pass --overwrite")
|
||||||
|
report_path.write_text(
|
||||||
|
json.dumps(report, indent=2, ensure_ascii=False) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
print(f"Saved reports to {output_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
101
test_adjudicate_easycom_interaction_events.py
Normal file
101
test_adjudicate_easycom_interaction_events.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""Tests for DeepSeek interaction-event adjudication without network calls."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from adjudicate_easycom_interaction_events import (
|
||||||
|
OUTPUT_SCHEMA_VERSION,
|
||||||
|
PROMPT_VERSION,
|
||||||
|
_validate_decisions,
|
||||||
|
_validate_resume_rows,
|
||||||
|
build_user_prompt,
|
||||||
|
stratified_limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdjudicationTests(unittest.TestCase):
|
||||||
|
def test_prompt_contains_json_and_candidate_context(self) -> None:
|
||||||
|
prompt = build_user_prompt(
|
||||||
|
[{"uid": "u1", "event_type": "backchannel_candidate", "transcription": "Yeah."}],
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
self.assertIn("JSON", prompt)
|
||||||
|
self.assertIn("u1", prompt)
|
||||||
|
self.assertIn("Yeah.", prompt)
|
||||||
|
|
||||||
|
def test_response_requires_exact_uid_set(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "mismatch"):
|
||||||
|
_validate_decisions({"items": []}, ["u1"])
|
||||||
|
|
||||||
|
def test_valid_response_is_returned_in_input_order(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"items": [
|
||||||
|
{"uid": "u2", "label": "uncertain", "confidence": "low", "rationale": "Not enough evidence."},
|
||||||
|
{"uid": "u1", "label": "backchannel", "confidence": "high", "rationale": "Floor is retained."},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
actual = _validate_decisions(payload, ["u1", "u2"])
|
||||||
|
self.assertEqual([row["uid"] for row in actual], ["u1", "u2"])
|
||||||
|
|
||||||
|
def test_invalid_label_is_rejected(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"items": [
|
||||||
|
{"uid": "u1", "label": "guess", "confidence": "high", "rationale": "No."}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
with self.assertRaisesRegex(ValueError, "Invalid label"):
|
||||||
|
_validate_decisions(payload, ["u1"])
|
||||||
|
|
||||||
|
def test_resume_rejects_mixed_model(self) -> None:
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"uid": "u1",
|
||||||
|
"schema_version": OUTPUT_SCHEMA_VERSION,
|
||||||
|
"model": "different-model",
|
||||||
|
"prompt_version": PROMPT_VERSION,
|
||||||
|
"passes": 2,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
with self.assertRaisesRegex(ValueError, "different adjudication"):
|
||||||
|
_validate_resume_rows(rows, model="deepseek-v4-flash", passes=2)
|
||||||
|
|
||||||
|
def test_resume_accepts_matching_configuration(self) -> None:
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"uid": "u1",
|
||||||
|
"schema_version": OUTPUT_SCHEMA_VERSION,
|
||||||
|
"model": "deepseek-v4-flash",
|
||||||
|
"prompt_version": PROMPT_VERSION,
|
||||||
|
"passes": 2,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
_validate_resume_rows(rows, model="deepseek-v4-flash", passes=2)
|
||||||
|
|
||||||
|
def test_pilot_limit_is_stratified_and_deterministic(self) -> None:
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"uid": f"{event_type}-{session}-{index}",
|
||||||
|
"event_type": event_type,
|
||||||
|
"session": session,
|
||||||
|
}
|
||||||
|
for event_type in ("backchannel_candidate", "interruption_candidate")
|
||||||
|
for session in ("Session_4", "Session_5")
|
||||||
|
for index in range(3)
|
||||||
|
]
|
||||||
|
first = stratified_limit(rows, 4, seed=2027)
|
||||||
|
second = stratified_limit(rows, 4, seed=2027)
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
self.assertEqual(
|
||||||
|
{(row["event_type"], row["session"]) for row in first},
|
||||||
|
{
|
||||||
|
("backchannel_candidate", "Session_4"),
|
||||||
|
("backchannel_candidate", "Session_5"),
|
||||||
|
("interruption_candidate", "Session_4"),
|
||||||
|
("interruption_candidate", "Session_5"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
108
test_derive_easycom_interaction_events.py
Normal file
108
test_derive_easycom_interaction_events.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Focused tests for transcript-derived EasyCom interaction proxies."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from derive_easycom_interaction_events import Rules, Utterance, classify_utterance
|
||||||
|
|
||||||
|
|
||||||
|
def utterance(
|
||||||
|
uid: str,
|
||||||
|
participant: int,
|
||||||
|
start: float,
|
||||||
|
end: float,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
target: tuple[int | str, ...] = ("Group",),
|
||||||
|
) -> Utterance:
|
||||||
|
return Utterance(
|
||||||
|
uid=uid,
|
||||||
|
session="Session_7",
|
||||||
|
clip_stem="00-00-000",
|
||||||
|
clip_index=0,
|
||||||
|
segment_id=0,
|
||||||
|
participant_id=participant,
|
||||||
|
target_of_speech=target,
|
||||||
|
transcription=text,
|
||||||
|
source_start_frame=round(start * 20) + 1,
|
||||||
|
source_end_frame=round(end * 20),
|
||||||
|
dense_start_frame=round(start * 20),
|
||||||
|
dense_end_frame_exclusive=round(end * 20),
|
||||||
|
dense_start_seconds=start,
|
||||||
|
dense_end_seconds=end,
|
||||||
|
duration_seconds=end - start,
|
||||||
|
starts_at_clip_boundary=False,
|
||||||
|
ends_at_clip_boundary=False,
|
||||||
|
annotation_end_frame_clamped_by=0,
|
||||||
|
is_cross_clip_continuation=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionProxyTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.rules = Rules()
|
||||||
|
|
||||||
|
def classify(self, target: Utterance, rows: list[Utterance]):
|
||||||
|
return classify_utterance(
|
||||||
|
target,
|
||||||
|
rows,
|
||||||
|
camera_wearer_id=2,
|
||||||
|
rules=self.rules,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_short_feedback_inside_retained_floor_is_backchannel_proxy(self) -> None:
|
||||||
|
floor = utterance("floor", 4, 0.0, 4.0, "Long explanation")
|
||||||
|
feedback = utterance("feedback", 2, 1.0, 1.4, "Yeah.", target=(4,))
|
||||||
|
event = self.classify(feedback, [floor, feedback])
|
||||||
|
self.assertEqual(event.event_type, "backchannel_proxy_high")
|
||||||
|
self.assertTrue(event.trainable_auxiliary)
|
||||||
|
self.assertEqual(event.retained_floor_participant_id, 4)
|
||||||
|
|
||||||
|
def test_short_answer_without_floor_return_is_not_backchannel(self) -> None:
|
||||||
|
question = utterance("question", 4, 0.0, 1.0, "Ready?")
|
||||||
|
answer = utterance("answer", 2, 1.1, 2.3, "Yeah, let us start.", target=(4,))
|
||||||
|
event = self.classify(answer, [question, answer])
|
||||||
|
self.assertEqual(event.event_type, "ordinary_switch")
|
||||||
|
|
||||||
|
def test_overlap_takeover_after_cutoff_is_interruption_proxy(self) -> None:
|
||||||
|
old = utterance("old", 4, 0.0, 2.0, "I was going to-")
|
||||||
|
incoming = utterance("incoming", 2, 1.7, 3.2, "Wait, that is wrong.", target=(4,))
|
||||||
|
event = self.classify(incoming, [old, incoming])
|
||||||
|
self.assertEqual(event.event_type, "interruption_proxy_high")
|
||||||
|
self.assertTrue(event.trainable_auxiliary)
|
||||||
|
|
||||||
|
def test_overlap_takeover_without_cutoff_is_not_called_interruption(self) -> None:
|
||||||
|
old = utterance("old", 4, 0.0, 2.0, "I finished my point.")
|
||||||
|
incoming = utterance("incoming", 2, 1.7, 3.2, "Here is my response.", target=(4,))
|
||||||
|
event = self.classify(incoming, [old, incoming])
|
||||||
|
self.assertEqual(event.event_type, "overlap_takeover")
|
||||||
|
self.assertTrue(event.trainable_auxiliary)
|
||||||
|
|
||||||
|
def test_short_nonlexical_floor_return_is_masked_candidate(self) -> None:
|
||||||
|
before = utterance("before", 4, 0.0, 1.2, "First part")
|
||||||
|
brief = utterance("brief", 2, 1.25, 1.8, "Possibly.", target=(4,))
|
||||||
|
after = utterance("after", 4, 1.9, 3.0, "Second part")
|
||||||
|
event = self.classify(brief, [before, brief, after])
|
||||||
|
self.assertEqual(event.event_type, "backchannel_candidate")
|
||||||
|
self.assertFalse(event.trainable_auxiliary)
|
||||||
|
|
||||||
|
def test_hesitation_marker_is_not_nonverbal_feedback(self) -> None:
|
||||||
|
floor = utterance("floor", 4, 0.0, 4.0, "Long explanation")
|
||||||
|
hesitation = utterance("hesitation", 2, 1.0, 1.2, "[H]", target=(4,))
|
||||||
|
event = self.classify(hesitation, [floor, hesitation])
|
||||||
|
self.assertEqual(event.event_type, "nonverbal_candidate")
|
||||||
|
self.assertFalse(event.trainable_auxiliary)
|
||||||
|
|
||||||
|
def test_cross_clip_continuation_never_creates_switch(self) -> None:
|
||||||
|
row = utterance("continued", 2, 60.0, 61.0, "[X] continued")
|
||||||
|
row = Utterance(
|
||||||
|
**{**row.__dict__, "is_cross_clip_continuation": True}
|
||||||
|
)
|
||||||
|
event = self.classify(row, [row])
|
||||||
|
self.assertEqual(event.event_type, "cross_clip_continuation")
|
||||||
|
self.assertFalse(event.trainable_auxiliary)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user