docs(diarization): добавлены отчёт и скрипт калибровки

- Зачем:
  - необходим устойчивый дефолт модели эмбеддингов и порога на русской речи.
- Что:
  - задокументирован выбор WeSpeaker ResNet34 LM с порогом 0,89.
  - добавлен возобновляемый скрипт свипа моделей и параметров диаризации.
- Проверка:
  - uvx --cache-dir .uv-cache ruff check scripts/benchmarks/diarization_calibration.py.
  - uv run --cache-dir .uv-cache pytest: 243 passed, 1 skipped.
This commit is contained in:
Dmitriy Dementiev
2026-08-14 11:18:17 +03:00
parent 8c55eaa87f
commit 0fdeebb256
2 changed files with 564 additions and 0 deletions
@@ -0,0 +1,355 @@
"""Воспроизводимый свип параметров офлайн-диаризации sherpa-onnx."""
from __future__ import annotations
import argparse
import hashlib
import itertools
import json
import re
import subprocess
import time
import wave
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
import sherpa_onnx
TURN_RE = re.compile(r"^\*\*\[(\d{2}):(\d{2})(?::(\d{2}))?\] Speaker (\d+):\*\*")
@dataclass(frozen=True)
class Recording:
name: str
path: Path
start: float
duration: float
expected_speakers: int
reference: Path | None
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--work-dir", type=Path, required=True)
parser.add_argument("--threads", type=int, default=8)
return parser.parse_args()
def file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest().upper()
def decode_clip(recording: Recording, work_dir: Path) -> Path:
output = work_dir / f"{recording.name}.wav"
if output.exists():
return output
command = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-y",
"-ss",
str(recording.start),
"-t",
str(recording.duration),
"-i",
str(recording.path),
"-vn",
"-ac",
"1",
"-ar",
"16000",
"-c:a",
"pcm_s16le",
str(output),
]
subprocess.run(command, check=True)
return output
def read_wav(path: Path) -> np.ndarray:
with wave.open(str(path), "rb") as source:
if source.getnchannels() != 1 or source.getsampwidth() != 2:
raise ValueError(f"Ожидался mono PCM16 WAV: {path}")
if source.getframerate() != 16000:
raise ValueError(f"Ожидалась частота 16 кГц: {path}")
samples = np.frombuffer(source.readframes(source.getnframes()), np.int16)
return samples.astype(np.float32) / 32768.0
def timestamp_seconds(match: re.Match[str]) -> float:
first, second, third = match.group(1), match.group(2), match.group(3)
if third is None:
return int(first) * 60 + int(second)
return int(first) * 3600 + int(second) * 60 + int(third)
def read_reference_turns(recording: Recording) -> list[dict[str, Any]]:
if recording.reference is None:
return []
starts: list[tuple[float, str]] = []
for line in recording.reference.read_text(encoding="utf-8").splitlines():
match = TURN_RE.match(line)
if match:
starts.append((timestamp_seconds(match), match.group(4)))
clip_end = recording.start + recording.duration
turns: list[dict[str, Any]] = []
for index, (start, speaker) in enumerate(starts):
end = starts[index + 1][0] if index + 1 < len(starts) else clip_end
overlap_start = max(start, recording.start)
overlap_end = min(end, clip_end)
if overlap_end > overlap_start:
turns.append(
{
"speaker": speaker,
"start": overlap_start - recording.start,
"end": overlap_end - recording.start,
}
)
return turns
def interval_overlap(left: dict[str, Any], right: dict[str, Any]) -> float:
return max(0.0, min(left["end"], right["end"]) - max(left["start"], right["start"]))
def best_mapping(
segments: list[dict[str, Any]],
reference_turns: list[dict[str, Any]],
) -> dict[str, Any] | None:
if not reference_turns or not segments:
return None
predicted = sorted({str(segment["speaker"]) for segment in segments})
reference = sorted({str(turn["speaker"]) for turn in reference_turns})
overlap: defaultdict[tuple[str, str], float] = defaultdict(float)
total = 0.0
for segment in segments:
predicted_speaker = str(segment["speaker"])
for turn in reference_turns:
value = interval_overlap(segment, turn)
if value:
reference_speaker = str(turn["speaker"])
overlap[(predicted_speaker, reference_speaker)] += value
total += value
best_score = -1.0
best_pairs: list[tuple[str, str]] = []
if len(predicted) >= len(reference):
for candidate in itertools.permutations(predicted, len(reference)):
pairs = list(zip(candidate, reference, strict=True))
score = sum(overlap[pair] for pair in pairs)
if score > best_score:
best_score, best_pairs = score, pairs
else:
for candidate in itertools.permutations(reference, len(predicted)):
pairs = list(zip(predicted, candidate, strict=True))
score = sum(overlap[pair] for pair in pairs)
if score > best_score:
best_score, best_pairs = score, pairs
return {
"mapped_speaker_purity": best_score / total if total else None,
"mapped_overlap_seconds": best_score,
"total_overlap_seconds": total,
"mapping": {predicted: reference for predicted, reference in best_pairs},
}
def make_config(
segmentation_model: Path,
embedding_model: Path,
threshold: float,
num_clusters: int,
threads: int,
) -> sherpa_onnx.OfflineSpeakerDiarizationConfig:
pyannote = sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig(
model=str(segmentation_model)
)
segmentation = sherpa_onnx.OfflineSpeakerSegmentationModelConfig(
pyannote=pyannote,
num_threads=threads,
)
embedding = sherpa_onnx.SpeakerEmbeddingExtractorConfig(
model=str(embedding_model),
num_threads=threads,
)
clustering = sherpa_onnx.FastClusteringConfig(
num_clusters=num_clusters,
threshold=threshold,
)
return sherpa_onnx.OfflineSpeakerDiarizationConfig(
segmentation=segmentation,
embedding=embedding,
clustering=clustering,
)
def summarize_segments(
segments: list[dict[str, Any]],
recording: Recording,
) -> dict[str, Any]:
durations: defaultdict[str, float] = defaultdict(float)
for segment in segments:
durations[str(segment["speaker"])] += segment["end"] - segment["start"]
ordered = sorted(durations.items(), key=lambda item: item[1], reverse=True)
total = sum(durations.values())
residual = sum(duration for _, duration in ordered[recording.expected_speakers :])
substantial_threshold = max(5.0, recording.duration * 0.02)
return {
"clusters": len(ordered),
"substantial_clusters": sum(
duration >= substantial_threshold for _, duration in ordered
),
"substantial_threshold_seconds": substantial_threshold,
"cluster_durations_seconds": dict(ordered),
"speaker_time_seconds": total,
"residual_seconds_after_expected": residual,
"residual_share_after_expected": residual / total if total else None,
}
def save_output(path: Path, output: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(output, ensure_ascii=False, indent=2),
encoding="utf-8",
)
temporary.replace(path)
def manifest_shape(manifest: dict[str, Any]) -> dict[str, Any]:
"""Отделить параметры эксперимента от машинно-зависимых путей."""
return {
"models": [item["name"] for item in manifest["models"]],
"recordings": [
{
key: item[key]
for key in ("name", "start", "duration", "expected_speakers")
}
for item in manifest["recordings"]
],
"runs": manifest["runs"],
}
def main() -> None:
args = parse_args()
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
args.work_dir.mkdir(parents=True, exist_ok=True)
recordings = [
Recording(
name=item["name"],
path=Path(item["path"]),
start=float(item["start"]),
duration=float(item["duration"]),
expected_speakers=int(item["expected_speakers"]),
reference=Path(item["reference"]) if item.get("reference") else None,
)
for item in manifest["recordings"]
]
if args.output.exists():
output = json.loads(args.output.read_text(encoding="utf-8"))
if (
manifest_shape(output["manifest"]) != manifest_shape(manifest)
or output["threads"] != args.threads
):
raise ValueError("Существующий output создан с другим manifest/threads")
output["manifest"] = manifest
else:
output = {
"manifest": manifest,
"sherpa_onnx_version": sherpa_onnx.__version__,
"threads": args.threads,
"results": [],
}
completed = {
(item["recording"], item["model"], item["run"]) for item in output["results"]
}
segmentation_model = Path(manifest["segmentation_model"])
for recording in recordings:
print(f"Декодирование {recording.name}", flush=True)
wav_path = decode_clip(recording, args.work_dir)
samples = read_wav(wav_path)
reference_turns = read_reference_turns(recording)
source_hash = file_sha256(recording.path)
for model in manifest["models"]:
embedding_model = Path(model["path"])
for run in manifest["runs"]:
run_key = (recording.name, model["name"], run["name"])
if run_key in completed:
print(f"Пропуск готового прогона: {run_key}", flush=True)
continue
num_clusters = run["num_clusters"]
if num_clusters == "expected":
num_clusters = recording.expected_speakers
threshold = float(run["threshold"])
print(
f"{recording.name}: {model['name']} / {run['name']}",
flush=True,
)
config = make_config(
segmentation_model=segmentation_model,
embedding_model=embedding_model,
threshold=threshold,
num_clusters=int(num_clusters),
threads=args.threads,
)
diarizer = sherpa_onnx.OfflineSpeakerDiarization(config)
started = time.perf_counter()
result = diarizer.process(samples)
elapsed = time.perf_counter() - started
segments = [
{
"speaker": int(segment.speaker),
"start": float(segment.start),
"end": float(segment.end),
}
for segment in result.sort_by_start_time()
]
item = {
"recording": recording.name,
"source": recording.path.name,
"source_sha256": source_hash,
"clip_start": recording.start,
"clip_duration": recording.duration,
"expected_speakers": recording.expected_speakers,
"reference": recording.reference.name
if recording.reference
else None,
"model": model["name"],
"model_file": embedding_model.name,
"run": run["name"],
"threshold": threshold,
"num_clusters": int(num_clusters),
"elapsed_seconds": elapsed,
"rtf": elapsed / recording.duration,
"summary": summarize_segments(segments, recording),
"reference_mapping": best_mapping(segments, reference_turns),
"segments": segments,
}
output["results"].append(item)
completed.add(run_key)
save_output(args.output, output)
if __name__ == "__main__":
main()