358 lines
18 KiB
Python
358 lines
18 KiB
Python
"""Run only on 2033lab04ztr; point scoring without reply/timeout gates.
|
|||
|
|
|
||
|
|
Primary score: softmax(complete, incomplete)[complete] at the latest causal
|
||
|
|
chunk. It is a conditional two-token score, not a calibrated probability of
|
||
|
|
replying. We also retain the native five-state argmax and official corrected
|
||
|
|
state. ASR is the model's own Paraformer, never the annotation transcript.
|
||
|
|
"""
|
||
|
|
import argparse
|
||
|
|
from collections import Counter
|
||
|
|
import csv
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
import hashlib
|
||
|
|
import importlib.metadata
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
from pathlib import Path
|
||
|
|
import platform
|
||
|
|
import random
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
|
||
|
|
ROOT = Path('/mnt/58462F4C462F29E8/xiaobailing-turn-detection-experiments')
|
||
|
|
REPO = ROOT / 'SoulX-Duplug'
|
||
|
|
OUT = ROOT / 'results/work-data-can-reply'
|
||
|
|
for name, value in {
|
||
|
|
'HF_ENDPOINT': 'https://hf-mirror.com',
|
||
|
|
'HF_HOME': str(ROOT / 'cache/huggingface'),
|
||
|
|
'MODELSCOPE_CACHE': str(ROOT / 'cache/modelscope'),
|
||
|
|
'XDG_CACHE_HOME': str(ROOT / 'cache/xdg'),
|
||
|
|
'TMPDIR': str(ROOT / 'cache/tmp'),
|
||
|
|
'TOKENIZERS_PARALLELISM': 'false',
|
||
|
|
'TQDM_DISABLE': '1',
|
||
|
|
}.items():
|
||
|
|
os.environ[name] = value
|
||
|
|
os.chdir(REPO)
|
||
|
|
sys.path.insert(0, str(REPO))
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
import soundfile as sf
|
||
|
|
import soxr
|
||
|
|
import torch
|
||
|
|
from service.model import load_turn_model
|
||
|
|
from model.model import State_Prediction_Model
|
||
|
|
|
||
|
|
TOKENS = ('<|user_complete|>', '<|user_incomplete|>', '<|user_idle|>',
|
||
|
|
'<|user_nonidle|>', '<|user_backchannel|>')
|
||
|
|
|
||
|
|
|
||
|
|
def sha(path):
|
||
|
|
digest = hashlib.sha256()
|
||
|
|
with Path(path).open('rb') as f:
|
||
|
|
for b in iter(lambda: f.read(8*1024*1024), b''):
|
||
|
|
digest.update(b)
|
||
|
|
return digest.hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
def read(path):
|
||
|
|
with Path(path).open(encoding='utf-8-sig', newline='') as f:
|
||
|
|
return list(csv.DictReader(f))
|
||
|
|
|
||
|
|
|
||
|
|
def write(path, rows):
|
||
|
|
if not rows:
|
||
|
|
return
|
||
|
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
with Path(path).open('w', encoding='utf-8-sig', newline='') as f:
|
||
|
|
w = csv.DictWriter(f, fieldnames=list(rows[0]))
|
||
|
|
w.writeheader()
|
||
|
|
w.writerows(rows)
|
||
|
|
|
||
|
|
|
||
|
|
def save(path, data):
|
||
|
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n')
|
||
|
|
|
||
|
|
|
||
|
|
def metrics(rows, threshold=.5, native=False):
|
||
|
|
c = Counter((int(r['can_reply_now']), int(r['raw_state'] == TOKENS[0])
|
||
|
|
if native else int(r['probability'] > threshold)) for r in rows)
|
||
|
|
tn, fp, fn, tp = c[0,0], c[0,1], c[1,0], c[1,1]
|
||
|
|
ratio=lambda n,d:n/d if d else None
|
||
|
|
return dict(n=tn+fp+fn+tp, tn=tn, fp=fp, fn=fn, tp=tp,
|
||
|
|
accuracy=ratio(tn+tp,tn+fp+fn+tp), false_reply_rate=ratio(fp,fp+tn),
|
||
|
|
false_hold_rate=ratio(fn,fn+tp))
|
||
|
|
|
||
|
|
|
||
|
|
def stats(values):
|
||
|
|
a = np.asarray(values, dtype=float)
|
||
|
|
return dict(n=len(a), mean_ms=float(a.mean()), median_ms=float(np.median(a)),
|
||
|
|
p95_ms=float(np.percentile(a,95)), p99_ms=float(np.percentile(a,99)),
|
||
|
|
min_ms=float(a.min()), max_ms=float(a.max())) if len(a) else None
|
||
|
|
|
||
|
|
|
||
|
|
class Instrument:
|
||
|
|
def __init__(self, model):
|
||
|
|
self.model = model
|
||
|
|
self.phase = ''
|
||
|
|
self.ids = [model.model.tokenizer.convert_tokens_to_ids(t) for t in TOKENS]
|
||
|
|
assert len(set(self.ids)) == 5
|
||
|
|
self.latest = {}
|
||
|
|
self.parts = {}
|
||
|
|
self.asr_calls = 0
|
||
|
|
model.model.llm.register_forward_hook(self.capture)
|
||
|
|
for method, phase in (('_audio_to_tokens','audio_tokens'), ('_asr','asr_stage'),
|
||
|
|
('_state_predict','state_model')):
|
||
|
|
original = getattr(model, method)
|
||
|
|
def wrapped(*args, _fn=original, _phase=phase, **kwargs):
|
||
|
|
previous = self.phase
|
||
|
|
self.phase = _phase
|
||
|
|
torch.cuda.synchronize()
|
||
|
|
begin = time.perf_counter()
|
||
|
|
try:
|
||
|
|
return _fn(*args, **kwargs)
|
||
|
|
finally:
|
||
|
|
torch.cuda.synchronize()
|
||
|
|
self.parts[_phase+'_ms'] = (time.perf_counter()-begin)*1000
|
||
|
|
self.phase = previous
|
||
|
|
setattr(model, method, wrapped)
|
||
|
|
original_asr = model.cascade_asr.recognize
|
||
|
|
def recognize(*args, **kwargs):
|
||
|
|
torch.cuda.synchronize()
|
||
|
|
began = time.perf_counter()
|
||
|
|
result = original_asr(*args, **kwargs)
|
||
|
|
torch.cuda.synchronize()
|
||
|
|
self.parts['cascade_asr_ms'] += (time.perf_counter()-began)*1000
|
||
|
|
self.asr_calls += 1
|
||
|
|
return result
|
||
|
|
model.cascade_asr.recognize = recognize
|
||
|
|
|
||
|
|
def capture(self, _module, _args, output):
|
||
|
|
if self.phase != 'state_model':
|
||
|
|
return
|
||
|
|
full = output.logits[0,-1].detach()
|
||
|
|
selected = full[self.ids].float().cpu().numpy()
|
||
|
|
pair = torch.softmax(full[self.ids[:2]].float(), dim=0).cpu().numpy()
|
||
|
|
self.latest = dict(probability=float(pair[0]),
|
||
|
|
raw_state=self.model.model.tokenizer.decode(int(full.argmax().item())),
|
||
|
|
state_logits=dict(zip(TOKENS, map(float,selected))))
|
||
|
|
|
||
|
|
def reset(self):
|
||
|
|
np.random.seed(42)
|
||
|
|
random.seed(42)
|
||
|
|
torch.manual_seed(42)
|
||
|
|
torch.cuda.manual_seed_all(42)
|
||
|
|
self.model.reset()
|
||
|
|
|
||
|
|
def infer(self, current, back, ahead):
|
||
|
|
self.parts = dict(cascade_asr_ms=0.)
|
||
|
|
self.asr_calls = 0
|
||
|
|
torch.cuda.synchronize()
|
||
|
|
begin = time.perf_counter()
|
||
|
|
# Direct infer retains native KV/state prediction. It bypasses the
|
||
|
|
# service's far-field filter, timeout replies, complete-trigger resets,
|
||
|
|
# and final utterance ASR, none of which define this point classifier.
|
||
|
|
state, delta, text = self.model.infer(current, back, ahead)
|
||
|
|
torch.cuda.synchronize()
|
||
|
|
total = (time.perf_counter()-begin)*1000
|
||
|
|
return dict(self.latest, state=state, asr_delta=delta, asr_buffer=text,
|
||
|
|
total_ms=total, asr_calls=self.asr_calls, **self.parts)
|
||
|
|
|
||
|
|
|
||
|
|
def load():
|
||
|
|
torch.set_num_threads(4)
|
||
|
|
torch.set_num_interop_threads(1)
|
||
|
|
torch.cuda.reset_peak_memory_stats()
|
||
|
|
began = time.perf_counter()
|
||
|
|
loading_diagnostics=[]
|
||
|
|
original_load=State_Prediction_Model.load_state_dict
|
||
|
|
def checked_load(instance,state_dict,*args,**kwargs):
|
||
|
|
result=original_load(instance,state_dict,*args,**kwargs)
|
||
|
|
assert not result.missing_keys, f'Missing trained model weights: {result.missing_keys}'
|
||
|
|
assert set(result.unexpected_keys) <= {'embed_tokens_func.weight'}, result.unexpected_keys
|
||
|
|
if 'embed_tokens_func.weight' in result.unexpected_keys:
|
||
|
|
alias=state_dict['embed_tokens_func.weight']
|
||
|
|
matching=[v for k,v in state_dict.items() if k.endswith('embed_tokens.weight')
|
||
|
|
and v.shape==alias.shape and torch.equal(v,alias)]
|
||
|
|
assert matching, 'Unexpected embedding weight is not a duplicate alias'
|
||
|
|
loading_diagnostics.append(dict(missing=result.missing_keys,unexpected=result.unexpected_keys))
|
||
|
|
return result
|
||
|
|
State_Prediction_Model.load_state_dict=checked_load
|
||
|
|
model = load_turn_model(str(REPO/'config/config.yaml'))
|
||
|
|
State_Prediction_Model.load_state_dict=original_load
|
||
|
|
torch.cuda.synchronize()
|
||
|
|
meta = dict(host='2033lab04ztr', hostname=platform.node(), python=sys.version,
|
||
|
|
started_at_utc=datetime.now(timezone.utc).isoformat(),
|
||
|
|
gpu_driver=subprocess.check_output(['nvidia-smi','--query-gpu=driver_version',
|
||
|
|
'--format=csv,noheader'],text=True).strip(),
|
||
|
|
gpu=torch.cuda.get_device_name(0), cpu_threads=4, torch=torch.__version__,
|
||
|
|
cuda=torch.version.cuda, load_ms=(time.perf_counter()-began)*1000,
|
||
|
|
parameter_dtypes=sorted({str(p.dtype) for p in model.model.parameters()}),
|
||
|
|
declared_precision=model.config.infer_config.precision,
|
||
|
|
actual_precision_note='Official loader does not cast to bf16; no autocast added.',
|
||
|
|
checkpoint_loading=loading_diagnostics,
|
||
|
|
repo_commit=subprocess.check_output(['git','-c',f'safe.directory={REPO}',
|
||
|
|
'rev-parse','HEAD'],cwd=REPO,text=True).strip(),
|
||
|
|
model_revision='61701c4ab8193cc1ee2220d3848872ac6c720142',
|
||
|
|
model_checksums=json.loads((ROOT/'results/model-download.json').read_text()),
|
||
|
|
label_sha256=sha(ROOT/'data/labels/eot_labels.csv'),
|
||
|
|
script_sha256=sha(__file__),
|
||
|
|
dependencies={k:importlib.metadata.version(k) for k in ('transformers',
|
||
|
|
'funasr','modelscope','peft','pytorch-lightning','numpy','soxr')})
|
||
|
|
return Instrument(model), meta
|
||
|
|
|
||
|
|
|
||
|
|
def point_audio(pcm, sr, decision):
|
||
|
|
# Target chunk ends 40ms before D; its 40ms lookahead ends exactly at D.
|
||
|
|
# <=20s total real audio. Cropping precedes resampling, including last chunk.
|
||
|
|
frames = (min(20000, decision)-40)//160
|
||
|
|
if frames < 1:
|
||
|
|
raise ValueError('Not enough past audio for a causal chunk')
|
||
|
|
origin = decision-40-frames*160
|
||
|
|
start, end = origin*sr//1000, decision*sr//1000
|
||
|
|
assert 0 <= start < end <= len(pcm)
|
||
|
|
began = time.perf_counter()
|
||
|
|
audio = np.ascontiguousarray(soxr.resample(pcm[start:end,0],sr,16000),dtype=np.float32)
|
||
|
|
resample_ms = (time.perf_counter()-began)*1000
|
||
|
|
assert len(audio) == frames*2560+640
|
||
|
|
return audio, origin, frames, resample_ms
|
||
|
|
|
||
|
|
|
||
|
|
def run_points(instrument, meta, smoke=False):
|
||
|
|
labels = read(ROOT/'data/labels/eot_labels.csv')
|
||
|
|
if smoke:
|
||
|
|
# A real speech checkpoint, not silence alone.
|
||
|
|
labels = [next(r for r in labels if r['call_id'].startswith('872a1e67')
|
||
|
|
and int(r['decision_ms']) == 17153)]
|
||
|
|
audio_cache, rows, traces = {}, [], []
|
||
|
|
began = time.perf_counter()
|
||
|
|
for index, label in enumerate(labels):
|
||
|
|
call, decision = label['call_id'], int(label['decision_ms'])
|
||
|
|
if call not in audio_cache:
|
||
|
|
path = ROOT/'data/audio'/f'{call}.mp3'
|
||
|
|
pcm, sr = sf.read(path,dtype='float32',always_2d=True)
|
||
|
|
assert pcm.shape[1] == 2
|
||
|
|
audio_cache[call] = pcm,sr
|
||
|
|
pcm,sr = audio_cache[call]
|
||
|
|
audio, origin, frames, resample_ms = point_audio(pcm,sr,decision)
|
||
|
|
instrument.reset()
|
||
|
|
# Official reset's tiny noise fills unavailable initial backward context.
|
||
|
|
back_initial = instrument.model.buffer[:15360].astype(np.float32)
|
||
|
|
padded = np.concatenate([back_initial,audio])
|
||
|
|
replay_ms = 0.
|
||
|
|
for j in range(frames):
|
||
|
|
start = 15360+j*2560
|
||
|
|
result = instrument.infer(padded[start:start+2560],
|
||
|
|
padded[start-15360:start],padded[start+2560:start+3200])
|
||
|
|
replay_ms += result['total_ms']
|
||
|
|
traces.append(dict(call_id=call,decision_ms=decision,frame=j,
|
||
|
|
current_end_ms=origin+(j+1)*160,
|
||
|
|
input_available_ms=origin+(j+1)*160+40,
|
||
|
|
state=result['state'],probability=result['probability'],
|
||
|
|
total_ms=result['total_ms'],audio_tokens_ms=result['audio_tokens_ms'],
|
||
|
|
asr_stage_ms=result['asr_stage_ms'],state_model_ms=result['state_model_ms'],
|
||
|
|
cascade_asr_ms=result['cascade_asr_ms'],asr_calls=result['asr_calls']))
|
||
|
|
prediction = int(result['probability'] > .5)
|
||
|
|
expected = int(label['can_reply_now'])
|
||
|
|
r = dict(label, probability=result['probability'], prediction=prediction,
|
||
|
|
correct=int(prediction==expected),
|
||
|
|
error_type='correct' if prediction==expected else ('false_reply' if prediction else 'false_hold'),
|
||
|
|
raw_state=result['raw_state'], corrected_state=result['state'],
|
||
|
|
state_logits=json.dumps(result['state_logits']), asr_text=result['asr_buffer'],
|
||
|
|
audio_start_ms=origin,audio_end_ms=decision,current_chunk_end_ms=decision-40,
|
||
|
|
lookahead_ms=40,frame_count=frames,context_ms=decision-origin,
|
||
|
|
replay_inference_ms=replay_ms,last_incremental_ms=result['total_ms'],
|
||
|
|
last_cascade_asr_ms=result['cascade_asr_ms'],resample_ms=resample_ms,
|
||
|
|
audio_channel=0,label_status='draft' if label['annotator']=='xlsx_draft' else 'unconfirmed_manual')
|
||
|
|
rows.append(r)
|
||
|
|
print(f"POINT {index+1}/{len(labels)} {call[:8]} {decision} label={expected} "
|
||
|
|
f"p_complete={r['probability']:.5f} raw={r['raw_state']} "
|
||
|
|
f"ASR={r['asr_text']!r} replay_ms={replay_ms:.1f}",flush=True)
|
||
|
|
# Partial results survive interrupted runs and are clearly incomplete.
|
||
|
|
destination = ROOT/'results/smoke' if smoke else OUT
|
||
|
|
write(destination/'predictions.csv',rows)
|
||
|
|
thresholds = [dict(threshold=t,**metrics(rows,t)) for t in (.1,.2,.3,.4,.5,.6,.7,.8,.9,.95,.99)]
|
||
|
|
summary = dict(total_points=len(rows),complete=len(rows)==(1 if smoke else 44),
|
||
|
|
metrics=metrics(rows),native_state_metrics=metrics(rows,native=True),
|
||
|
|
corrected_state_metrics=metrics([dict(r,raw_state=r['corrected_state']) for r in rows],native=True),
|
||
|
|
thresholds=thresholds,metadata=meta,
|
||
|
|
grouped={k:{v:metrics([r for r in rows if r[k]==v]) for v in sorted({r[k] for r in rows})}
|
||
|
|
for k in ('call_id','label_status','reason')},
|
||
|
|
elapsed_seconds=time.perf_counter()-began,
|
||
|
|
peak_gpu_allocated_bytes=torch.cuda.max_memory_allocated(),
|
||
|
|
peak_gpu_reserved_bytes=torch.cuda.max_memory_reserved(),
|
||
|
|
audio_sha256={call:sha(ROOT/'data/audio'/f'{call}.mp3') for call in audio_cache},
|
||
|
|
config=dict(sample_rate=16000,chunk_ms=160,back_ms=960,ahead_ms=40,
|
||
|
|
max_real_context_ms=20000,channel=0,threshold=.5,
|
||
|
|
rule='softmax(logit_complete,logit_incomplete)[complete] > threshold',
|
||
|
|
state_source='direct infer/_state_predict, no service action/timeout/reset gates'),
|
||
|
|
method='Each point resets native KV state, replays <=20s past user audio with built-in Paraformer. Final process chunk ends D-40ms and lookahead ends D. No label transcript input, no future audio, no fallback reply or complete-trigger reset.',
|
||
|
|
limitations=['Conditional two-token score is not a calibrated can_reply probability; raw five-state argmax retained as a diagnostic.',
|
||
|
|
'29 draft labels and 15 labels without verification metadata; selected-point exploratory evaluation.',
|
||
|
|
'Input channels/context and hardware differ across models; this is a configured-system comparison.',
|
||
|
|
'Independent point restart differs from continuous server state; ASR is part of SoulX inference.',
|
||
|
|
'Known ASR/label alignment issues at cb3238dc 13540 and e304f740 11531 remain unchanged.'])
|
||
|
|
save(destination/'summary.json',summary)
|
||
|
|
write(destination/'errors.csv',[r for r in rows if not r['correct']])
|
||
|
|
write(destination/'thresholds.csv',thresholds)
|
||
|
|
write(destination/'frame-times.csv',traces)
|
||
|
|
print(json.dumps(summary['metrics']),flush=True)
|
||
|
|
|
||
|
|
|
||
|
|
def benchmark(instrument,meta):
|
||
|
|
labels = read(ROOT/'data/labels/eot_labels.csv')
|
||
|
|
rows = []
|
||
|
|
for call in sorted({r['call_id'] for r in labels}):
|
||
|
|
decision = max(int(r['decision_ms']) for r in labels if r['call_id']==call)
|
||
|
|
pcm,sr = sf.read(ROOT/'data/audio'/f'{call}.mp3',dtype='float32',always_2d=True)
|
||
|
|
# No audio beyond the last checkpoint. Real continuous frame grid begins
|
||
|
|
# at call time 0; the final incomplete frame is not padded into the future.
|
||
|
|
audio = np.ascontiguousarray(soxr.resample(pcm[:decision*sr//1000,0],sr,16000),dtype=np.float32)
|
||
|
|
count = (len(audio)-640)//2560
|
||
|
|
instrument.reset()
|
||
|
|
padded = np.concatenate([instrument.model.buffer[:15360].astype(np.float32),audio])
|
||
|
|
for j in range(count):
|
||
|
|
p = 15360+j*2560
|
||
|
|
r = instrument.infer(padded[p:p+2560],padded[p-15360:p],padded[p+2560:p+3200])
|
||
|
|
rows.append(dict(call_id=call,frame=j,current_end_ms=(j+1)*160,
|
||
|
|
steady=int((j+1)*160>20000),total_ms=r['total_ms'],
|
||
|
|
audio_tokens_ms=r['audio_tokens_ms'],asr_stage_ms=r['asr_stage_ms'],
|
||
|
|
state_model_ms=r['state_model_ms'],cascade_asr_ms=r['cascade_asr_ms'],
|
||
|
|
non_cascade_ms=r['total_ms']-r['cascade_asr_ms'],asr_calls=r['asr_calls']))
|
||
|
|
if (j+1)%100==0:
|
||
|
|
print(f'BENCH {call[:8]} {j+1}/{count}',flush=True)
|
||
|
|
write(ROOT/'results/latency-frames.csv',rows)
|
||
|
|
fields=('total_ms','audio_tokens_ms','asr_stage_ms','state_model_ms','cascade_asr_ms','non_cascade_ms')
|
||
|
|
groups={'all':rows,'steady':[r for r in rows if r['steady']],
|
||
|
|
'asr_called':[r for r in rows if r['asr_calls']],
|
||
|
|
'no_asr':[r for r in rows if not r['asr_calls']]}
|
||
|
|
data=dict(metadata=meta,complete=True,update_interval_ms=160,lookahead_ms=40,
|
||
|
|
summary={g:{f:stats([r[f] for r in data]) for f in fields} for g,data in groups.items()},
|
||
|
|
realtime_factor=sum(r['total_ms'] for r in rows)/(len(rows)*160),
|
||
|
|
fraction_over_160ms=sum(r['total_ms']>160 for r in rows)/len(rows),
|
||
|
|
peak_gpu_allocated_bytes=torch.cuda.max_memory_allocated(),
|
||
|
|
peak_gpu_reserved_bytes=torch.cuda.max_memory_reserved(),
|
||
|
|
note='CUDA synchronized wall time. Includes waveform features, audio tokenizer, LLM state, cascade ASR and instrumentation. Excludes imports/model loading, file decode/resample, network, reply policy. Official FP32 loading retained; BF16 config does not cast weights.')
|
||
|
|
save(ROOT/'results/latency.json',data)
|
||
|
|
print(json.dumps(data['summary']['steady']['total_ms']),flush=True)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__=='__main__':
|
||
|
|
p=argparse.ArgumentParser()
|
||
|
|
p.add_argument('mode',choices=('smoke','evaluate','benchmark','all'))
|
||
|
|
args=p.parse_args()
|
||
|
|
instrument,meta=load()
|
||
|
|
save(ROOT/'results'/f'{args.mode}-environment.json',meta)
|
||
|
|
if args.mode=='all':
|
||
|
|
run_points(instrument,meta,smoke=True)
|
||
|
|
print('SMOKE_COMPLETE',flush=True)
|
||
|
|
run_points(instrument,meta)
|
||
|
|
print('EVALUATION_COMPLETE',flush=True)
|
||
|
|
benchmark(instrument,meta)
|
||
|
|
print('ALL_COMPLETE',flush=True)
|
||
|
|
elif args.mode=='benchmark':
|
||
|
|
benchmark(instrument,meta)
|
||
|
|
else:
|
||
|
|
run_points(instrument,meta,smoke=args.mode=='smoke')
|