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

170 lines
10 KiB
Python

"""Score VAP annotation points from already available continuous stream outputs.
No model inference, state resets, interpolation, or changes to archived results.
"""
import argparse
from bisect import bisect_right
from collections import Counter
from datetime import datetime, timezone
import csv
import hashlib
import json
import math
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
LABELS = ROOT / "labels/EOT标注/工作数据/eot_labels.csv"
SOURCES = {
"maai": ("score-timeline.json", "cpc-stereo", "CPC 降噪双声道"),
"maai_user_only": ("user-only/score-timeline.json", "cpc-user-only", "CPC 降噪机器人静音"),
"maai_mimi_standard": ("mimi-standard/score-timeline.json", "mimi-standard", "Mimi 标准双声道"),
"maai_mimi_mc": ("mimi-mc/score-timeline.json", "mimi-mc", "Mimi 降噪双声道"),
}
def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def last_available(points, decision_ms, max_age_ms):
"""The same inclusive past-only, bounded-age rule used by Studio scoreAt."""
index = bisect_right(points, decision_ms, key=lambda p: p[0]) - 1
if index < 0 or decision_ms - points[index][0] > max_age_ms:
return None
return points[index]
def classification(rows, threshold=.5):
labeled = [r for r in rows if str(r['can_reply_now']) in ('0', '1')]
available = [r for r in labeled if r['status'] == 'available']
counts = Counter((int(r['can_reply_now']), int(float(r['score']) > threshold)) for r in available)
tn, fp, fn, tp = counts[0,0], counts[0,1], counts[1,0], counts[1,1]
n = tn + fp + fn + tp
ratio = lambda a,b: a/b if b else None
return dict(n=n, requested_labeled=len(labeled), unavailable_labeled=len(labeled)-n,
tn=tn, fp=fp, fn=fn, tp=tp, correct=tn+tp, accuracy=ratio(tn+tp,n),
false_reply_rate=ratio(fp,fp+tn), false_hold_rate=ratio(fn,fn+tp),
recall=ratio(tp,tp+fn), precision=ratio(tp,tp+fp), f1=ratio(2*tp,2*tp+fp+fn))
def write_csv(path, rows):
if not rows:
return
with path.open('w', encoding='utf-8-sig', newline='') as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
def refresh_streaming(model):
source_file, directory, name = SOURCES[model]
source_path = HERE / 'results' / source_file
source_hash, label_hash = sha(source_path), sha(LABELS)
source = json.loads(source_path.read_text())
if source['model'] != model or source['schema_version'] != 1:
raise ValueError('Unexpected source export')
interval = source['sample_interval_ms']
assert math.isfinite(interval) and interval > 0
with LABELS.open(encoding='utf-8-sig', newline='') as f:
labels = list(csv.DictReader(f))
assert len({(r['call_id'], int(r['decision_ms'])) for r in labels}) == len(labels)
calls = {c['call_id']: c for c in source['calls']}
assert len(calls) == len(source['calls'])
assert {r['call_id'] for r in labels} <= calls.keys(), 'Missing annotated calls'
rows, exported_calls = [], []
for call_id, call in calls.items():
audio = ROOT / 'data' / call['audio_path']
assert audio.resolve().is_relative_to((ROOT/'data').resolve()), 'Audio outside project data'
assert sha(audio) == call['audio_sha256'], 'Source recording changed; rerun inference'
points = call['points']
assert all(len(p)==2 and math.isfinite(p[0]) and math.isfinite(p[1]) and
0 <= p[0] <= call['duration_ms'] and 0 <= p[1] <= 1 for p in points)
assert all(a[0] < b[0] for a,b in zip(points,points[1:]))
checkpoints, sources = [], []
for label in sorted([r for r in labels if r['call_id']==call_id], key=lambda r:int(r['decision_ms'])):
decision = int(label['decision_ms'])
assert 0 <= decision <= call['duration_ms'], 'Annotation outside recording'
point = last_available(points, decision, interval*2)
row = dict(label)
row.update(status='available' if point is not None else 'unavailable',
score_time_ms=point[0] if point is not None else '',
score_age_ms=decision-point[0] if point is not None else '',
score=point[1] if point is not None else '',
prediction_05=int(point[1]>.5) if point is not None else '')
rows.append(row)
if point is not None:
checkpoints.append([decision, point[1]])
sources.append([decision, point[0]])
exported_calls.append(dict(call_id=call_id, audio_path=call['audio_path'], audio_sha256=call['audio_sha256'],
duration_ms=call['duration_ms'], points=points,
checkpoints=checkpoints, checkpoint_sources=sources))
provenance = dict(source_export=str(source_path.relative_to(ROOT)), source_export_sha256=source_hash,
source_inference_provenance=source.get('provenance', {}),
source_model_sha256=source.get('model_sha256', {}),
label_sha256=label_hash, evaluation_script_sha256=sha(Path(__file__)))
method = ('Continuous full-call VAP outputs only. At each exact decision_ms, use the last score with '
'availability timestamp <= decision_ms and age <= 2 * sample_interval_ms. '
'No state reset, future score, interpolation or independent-window checkpoint. '
'Availability includes source resampler buffering; score is the stored activity-projection proxy.')
exported = dict(schema_version=1, model=model, name=source['name'], score=source['score'],
checkpoint_mode='streaming', sample_interval_ms=interval, method=method,
max_score_age_ms=interval*2, provenance=provenance, calls=exported_calls)
summary = dict(model=model, name=name, evaluation_mode='streaming',
created_at=datetime.now(timezone.utc).isoformat(), threshold=.5,
rule='1 - p_now[user] > 0.5', main_metrics=classification(rows),
thresholds=[dict(threshold=t, **classification(rows,t)) for t in (.11,.5,.7,.8,.9,.95,.98)],
per_call={c:classification([r for r in rows if r['call_id']==c]) for c in calls},
method=method, provenance=provenance,
limitations=['Selected-point classification on six recorded calls, not endpointing-policy cutoff/latency.',
'Uses stored rounded continuous scores; no new inference or model-speed measurement.',
'No prior output or stale output is unavailable, not a negative/zero prediction.',
'Archived independent-window results are a separate evaluation protocol.'])
assert sha(source_path)==source_hash and sha(LABELS)==label_hash, 'Inputs changed during evaluation'
output = HERE/'results/streaming'/directory
output.mkdir(parents=True, exist_ok=True)
write_csv(output/'predictions.csv', rows)
(output/'summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2)+'\n')
(output/'score-timeline.json').write_text(json.dumps(exported,ensure_ascii=False,separators=(',',':'))+'\n')
return summary, rows
def main(selected):
summaries, rows_by_model = {}, {}
for model in SOURCES if selected=='all' else (selected,):
summary, rows = refresh_streaming(model)
summaries[model], rows_by_model[model] = summary, rows
print(summary['name'], json.dumps(summary['main_metrics'],ensure_ascii=False),flush=True)
if selected != 'all':
return
label_hashes = {s['provenance']['label_sha256'] for s in summaries.values()}
assert len(label_hashes)==1, 'Models use different labels'
base = summaries['maai']['main_metrics']['accuracy']
for summary in summaries.values():
accuracy = summary['main_metrics']['accuracy']
summary['accuracy_delta_pp_vs_cpc_stereo'] = (accuracy-base)*100 if accuracy is not None and base is not None else None
summary['relative_accuracy_change_vs_cpc_stereo'] = (accuracy-base)/base if accuracy is not None and base else None
output = HERE/'results/streaming'
result = dict(evaluation_mode='streaming', label_sha256=next(iter(label_hashes)), models=summaries)
(output/'comparison-summary.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n')
lines = ['VAP 流式场景判断点对比(阈值严格 > 0.5)', '',
'模型 | 有效点/有标签点 | 正确数 | 准确率 | 误放行 FP | 误阻止 FN | 对 CPC 双声道变化']
for model, summary in summaries.items():
m = summary['main_metrics']
accuracy = f"{m['accuracy']:.2%}" if m['accuracy'] is not None else '不可用'
delta = summary['accuracy_delta_pp_vs_cpc_stereo']
delta_text = f'{delta:+.2f} 个百分点' if delta is not None else '不可用'
lines.append(f"{summary['name']} | {m['n']}/{m['requested_labeled']} | {m['correct']} | {accuracy} | {m['fp']} | {m['fn']} | {delta_text}")
lines += ['', '1. 本表使用连续推理在判断时刻已经产生的最新分数,与前端 VAP 圆点和悬停分数一致;不使用独立窗口结果。',
'2. 每个有效点记录 decision_ms、score_time_ms、score_age_ms;启动期或超过两倍更新间隔的旧分数不计为 0,而是不可用。',
'3. 数据为六通录音中的选定点,只做描述性比较;下一步宜扩充按通话隔离的测试集,再在验证集上选择阈值。',
'', '复现:在 MaAI 独立 uv 环境运行 python evaluate_streaming.py all。原独立窗口结果保留在原目录,另作历史诊断。']
(output/'对比分析.txt').write_text('\n'.join(lines)+'\n')
print('\n'.join(lines))
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('model',choices=('all',*SOURCES),default='all',nargs='?')
main(parser.parse_args().model)