Files
xiaobailing-turn-detection/experiments/maai-ch-kyoto/run_user_only.py
T

248 lines
14 KiB
Python

"""Evaluate VAP with channel 0=user and channel 1=zeros; export Studio scores.
Reuse the locked MaAI environment/weights. Independent point replays and the
continuous timeline are separate experiments. Never use annotation text as input.
"""
import argparse
from collections import Counter
from datetime import datetime, timezone
import hashlib
import importlib.metadata as metadata
import json
import platform
import random
import sys
import time
from smoke_run import HERE, ROOT, EXPECTED, WEIGHTS, CPC, np, sf, soxr, torch
from evaluate_can_reply import model, metrics, read, write
OUT = HERE / "results/user-only"
LABELS = ROOT / "labels/EOT标注/工作数据/eot_labels.csv"
HISTORY = LABELS.with_name("asr_history.csv")
SEED = 42
SCORE = "can_reply_score"
def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def seed():
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
def channels(pcm, robot_silent):
if pcm.ndim != 2 or pcm.shape[1] != 2:
raise ValueError("Expected channel 0=user, channel 1=robot")
if robot_silent:
# Do not mix, duplicate user audio, remove pauses, or alter source PCM.
result = np.zeros(pcm.shape, dtype=np.float32)
result[:, 0] = pcm[:, 0]
return result
return np.ascontiguousarray(pcm, dtype=np.float32)
def point_audio(pcm, sample_rate, decision_ms, robot_silent):
duration = min(20000, decision_ms // 100 * 100)
if duration <= 0:
raise ValueError("Point has less than 100 ms of history")
origin = decision_ms - duration
start, end = origin * sample_rate // 1000, decision_ms * sample_rate // 1000
if not 0 <= start < end <= len(pcm):
raise ValueError("Point outside recording")
# Crop received history BEFORE resampling, then mute before any model step.
segment = channels(pcm[start:end], robot_silent)
segment = np.ascontiguousarray(soxr.resample(segment, sample_rate, 16000, quality="HQ"), dtype=np.float32)
assert len(segment) == duration // 100 * 1600
if robot_silent:
assert np.count_nonzero(segment[:, 1]) == 0
return segment, origin
def replay(segment):
seed()
maai = model() # Reset both the CPC recurrent state and VAP KV cache.
maai.CALC_PROCESS_TIME_INTERVAL = 10**9
began = time.perf_counter()
for start in range(0, len(segment), 1600):
frame = segment[start:start + 1600]
assert len(frame) == 1600
maai.process(np.ascontiguousarray(frame[:, 0]), np.ascontiguousarray(frame[:, 1]))
result = maai.result_dict_queue.get_nowait()
assert maai.result_dict_queue.empty()
elapsed = (time.perf_counter() - began) * 1000
values = {key: np.asarray(result[key], dtype=float).reshape(-1) for key in ("p_now", "p_future", "vad")}
for value in values.values():
assert value.shape == (2,) and np.isfinite(value).all()
assert ((value >= 0) & (value <= 1)).all()
return values, elapsed
def score(values):
# Match the existing Studio score exactly. Speaker outputs have a small
# normalization epsilon, so 1-p_now[user] is not substituted by p_now[robot].
return 1.0 - float(values["p_now"][0])
def witness(pcm, sr, decision):
segment, _ = point_audio(pcm, sr, decision, True)
original_user = pcm[:, 0].copy()
changed = pcm.copy()
rng = np.random.default_rng(SEED)
changed[:, 1] = rng.normal(0, .1, len(changed))
changed[decision * sr // 1000:, 0] = rng.normal(0, .1, len(changed) - decision * sr // 1000)
other, _ = point_audio(changed, sr, decision, True)
assert np.array_equal(segment, other), "Robot/future audio leaked into model input"
assert np.array_equal(pcm[:, 0], original_user), "Source user PCM was mutated"
a, _ = replay(segment)
b, _ = replay(other)
assert all(np.array_equal(a[k], b[k]) for k in a), "Seeded inference is not repeatable"
print(f"WITNESS CPU seed={SEED}: robot/future invariance and repeat passed; score={score(a):.7f}", flush=True)
return dict(seed=SEED, robot_and_future_invariance=True, repeat_identical=True, score=score(a))
def timeline(pcm, sr):
seed()
maai = model()
maai.CALC_PROCESS_TIME_INTERVAL = 10**9
resampler = soxr.ResampleStream(sr, 16000, 2, dtype="float32", quality="HQ")
pending = np.empty((0, 2), dtype=np.float32)
points = []
for start in range(0, len(pcm), sr // 10):
end = min(len(pcm), start + sr // 10)
block = resampler.resample_chunk(channels(pcm[start:end], True), last=end == len(pcm))
pending = np.concatenate([pending, block])
latest = None
while len(pending) >= 1600:
frame, pending = pending[:1600], pending[1600:]
assert np.count_nonzero(frame[:, 1]) == 0
maai.process(np.ascontiguousarray(frame[:, 0]), np.ascontiguousarray(frame[:, 1]))
result = maai.result_dict_queue.get_nowait()
assert maai.result_dict_queue.empty()
latest = 1.0 - float(result["p_now"][0])
assert np.isfinite(latest) and 0 <= latest <= 1
if latest is not None:
# Availability time includes streaming resampler buffering. No
# unreceived audio or trailing synthetic silence is sent to VAP.
points.append([round(end * 1000 / sr, 3), round(latest, 7)])
return points
def provenance():
return dict(machine=platform.platform(), hardware="Apple M4 Mac mini / 16 GB", python=sys.version,
environment="experiments/maai-ch-kyoto/.venv (uv.lock)", device="cpu", threads=4,
dependencies={p: metadata.version(p) for p in ("maai", "torch", "transformers", "numpy", "soundfile", "soxr")},
sample_rate=16000, frame_ms=100, context_ms=20000, use_kv_cache=True, seed=SEED,
channel_mapping={"0": "original user", "1": "all-zero robot input"},
weight_sha256=EXPECTED, label_sha256=sha(LABELS), history_sha256=sha(HISTORY),
uv_lock_sha256=sha(HERE / "uv.lock"), script_sha256=sha(HERE / "run_user_only.py"),
model_factory_sha256=sha(HERE / "evaluate_can_reply.py"), loader_sha256=sha(HERE / "smoke_run.py"))
def main(mode):
torch.set_num_threads(4)
torch.set_num_interop_threads(1)
for path in (WEIGHTS, CPC):
if sha(path) != EXPECTED[path.name]:
raise ValueError(f"Weight checksum mismatch: {path}")
labels = read(LABELS)
keys = [(r["call_id"], int(r["decision_ms"])) for r in labels]
assert len(set(keys)) == len(keys), "Duplicate annotation key"
annotated = [r for r in labels if r["can_reply_now"] in ("0", "1")]
if not annotated:
raise ValueError("No binary annotation points")
prov = provenance()
audio_cache, audio_hashes = {}, {}
for call in sorted({r["call_id"] for r in labels}):
path = ROOT / "data/韩泰机器人抢话/音频" / f"{call}.mp3"
pcm, sr = sf.read(path, dtype="float32", always_2d=True)
assert pcm.shape[1] == 2
audio_cache[call] = pcm, sr
audio_hashes[call] = sha(path)
first = annotated[0]
began = time.perf_counter()
checks = witness(*audio_cache[first["call_id"]], int(first["decision_ms"]))
if mode == "smoke":
print(json.dumps(dict(witness=checks, provenance=prov), ensure_ascii=False, indent=2))
return
OUT.mkdir(parents=True, exist_ok=True)
rows = []
for index, label in enumerate(annotated):
call, decision = label["call_id"], int(label["decision_ms"])
pcm, sr = audio_cache[call]
silent_input, origin = point_audio(pcm, sr, decision, True)
silent, elapsed = replay(silent_input)
stereo_input, stereo_origin = point_audio(pcm, sr, decision, False)
assert origin == stereo_origin and np.array_equal(silent_input[:, 0], stereo_input[:, 0])
stereo, stereo_elapsed = replay(stereo_input)
value, baseline = score(silent), score(stereo)
row = dict(label)
row.update(can_reply_score=value, stereo_can_reply_score=baseline,
p_now_user=float(silent["p_now"][0]), p_now_robot=float(silent["p_now"][1]),
p_future_user=float(silent["p_future"][0]), p_future_robot=float(silent["p_future"][1]),
vad_user=float(silent["vad"][0]), vad_robot=float(silent["vad"][1]),
prediction_05=int(value > .5), correct_05=int(int(value > .5) == int(label["can_reply_now"])),
stereo_prediction_05=int(baseline > .5),
audio_start_ms=origin, audio_end_ms=decision, context_ms=decision-origin,
robot_input_peak_abs=float(np.max(np.abs(silent_input[:, 1]))),
replay_inference_ms=elapsed, stereo_replay_inference_ms=stereo_elapsed)
rows.append(row)
print(f"{index+1}/{len(annotated)} {call[:8]} {decision}: label={label['can_reply_now']}, user_only={value:.4f}, stereo={baseline:.4f}", flush=True)
# Guard against labels/history being edited during the run.
assert prov["label_sha256"] == sha(LABELS) and prov["history_sha256"] == sha(HISTORY)
write(OUT / "predictions.csv", rows)
# write() intentionally skips empty lists; remove any stale errors instead.
errors = [r for r in rows if not r["correct_05"]]
if errors:
write(OUT / "errors.csv", errors)
else:
(OUT / "errors.csv").unlink(missing_ok=True)
thresholds = [dict(threshold=t, **metrics(rows, SCORE, t)) for t in (.11, .5, .7, .8, .9, .95, .98)]
write(OUT / "thresholds.csv", thresholds)
summary = dict(model="VAP / MaAI (user only, silent robot)", total_points=len(rows),
created_at=datetime.now(timezone.utc).isoformat(), main_rule="1 - p_now[user] > 0.5",
main_metrics=metrics(rows, SCORE), thresholds=thresholds,
same_point_stereo_reference=metrics(rows, "stereo_can_reply_score"),
changed_vs_stereo=dict(fixed=sum(r["stereo_prediction_05"] != int(r["can_reply_now"]) and bool(r["correct_05"]) for r in rows),
regressed=sum(r["stereo_prediction_05"] == int(r["can_reply_now"]) and not r["correct_05"] for r in rows)),
per_call={call: metrics([r for r in rows if r["call_id"] == call], SCORE) for call in audio_cache},
annotation_sources=dict(Counter(r["annotator"] or "unspecified" for r in rows)),
skipped_unlabeled=len(labels)-len(annotated), witness=checks,
provenance={**prov, "audio_sha256": audio_hashes},
method="At each exact point, independently reset CPC/VAP and replay <=20s in 100ms frames. User channel unchanged; robot channel zero for the full context before resampling. Also rerun the stereo control on identical current points. No annotation transcripts, future audio, end signal, silence gate, or timeout. Score is an activity-projection proxy, not calibrated EOT probability.",
limitations=["Exploratory selected-point evaluation, not a held-out benchmark or endpointing-policy evaluation.",
"Continuous full-call KV/CPC state differs from independent point replay; curve and checkpoint scores may differ.",
"Zeroing the robot channel cannot remove robot leakage already present in the user channel.",
"Stereo reference here is rerun at current annotation points; existing legacy frontend checkpoints are preserved separately."])
if mode == "all":
calls = []
for call, (pcm, sr) in audio_cache.items():
points = timeline(pcm, sr)
call_rows = sorted([r for r in rows if r["call_id"] == call], key=lambda r: int(r["decision_ms"]))
calls.append(dict(call_id=call, audio_path=f"韩泰机器人抢话/音频/{call}.mp3", audio_sha256=audio_hashes[call],
duration_ms=round(len(pcm)*1000/sr, 3), points=points,
checkpoints=[[int(r["decision_ms"]), round(r[SCORE], 7)] for r in call_rows]))
print(f"{call[:8]} timeline: {len(points)} scores, {len(call_rows)} checkpoints", flush=True)
assert prov["label_sha256"] == sha(LABELS) and prov["history_sha256"] == sha(HISTORY)
exported = dict(schema_version=1, model="maai_user_only", name="VAP / MaAI(仅用户)", score="1 - p_now[user]",
sample_interval_ms=100, method="Continuous user + silent-robot stereo input, 16kHz, 10Hz, 20s KV cache. Robot channel zeroed before causal streaming resampling. Timestamps include resampling buffering. Checkpoints are independent <=20s replays.",
provenance=summary["provenance"], calls=calls)
(OUT / "score-timeline.json").write_text(json.dumps(exported, ensure_ascii=False, separators=(",", ":")) + "\n")
summary["timeline_points"] = sum(len(c["points"]) for c in calls)
summary["elapsed_seconds"] = time.perf_counter()-began
(OUT / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n")
print(json.dumps({k: summary[k] for k in ("main_metrics", "same_point_stereo_reference", "changed_vs_stereo", "elapsed_seconds")}, ensure_ascii=False, indent=2))
if mode == "all":
from evaluate_streaming import refresh_streaming
streaming, _ = refresh_streaming("maai_user_only")
print('PRIMARY streaming metrics:', json.dumps(streaming['main_metrics']), flush=True)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("mode", choices=("smoke", "evaluate", "all"))
main(parser.parse_args().mode)