From 0fdeebb256f50525fe767058888634b826080b65 Mon Sep 17 00:00:00 2001 From: Dmitriy Dementiev Date: Fri, 14 Aug 2026 11:18:17 +0300 Subject: [PATCH] =?UTF-8?q?docs(diarization):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BE=D1=82=D1=87=D1=91=D1=82?= =?UTF-8?q?=20=D0=B8=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B1=D1=80=D0=BE=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - необходим устойчивый дефолт модели эмбеддингов и порога на русской речи. - Что: - задокументирован выбор 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. --- .../2026-08-14-diarization-calibration.md | 209 +++++++++++ scripts/benchmarks/diarization_calibration.py | 355 ++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 docs/benchmarks/2026-08-14-diarization-calibration.md create mode 100644 scripts/benchmarks/diarization_calibration.py diff --git a/docs/benchmarks/2026-08-14-diarization-calibration.md b/docs/benchmarks/2026-08-14-diarization-calibration.md new file mode 100644 index 0000000..d5870a3 --- /dev/null +++ b/docs/benchmarks/2026-08-14-diarization-calibration.md @@ -0,0 +1,209 @@ +# Калибровка модели эмбеддингов и порога диаризации + +**Дата:** 2026-08-14 + +**Статус:** выбор конфигурации для проектирования. Не приёмка +производительности на целевом Intel Core i5 11-го поколения. + +## Решение + +Для автоматического определения числа участников использовать: + +- эмбеддинги `wespeaker_en_voxceleb_resnet34_LM.onnx`; +- `FastClusteringConfig.threshold=0.89`; +- `num_clusters=-1` по умолчанию. + +Если число участников известно, передавать его через `num_clusters`: это +устраняет остаточные кластеры и служит страховкой от особенностей записи. На +трёх проверенных фрагментах явное число участников не ухудшило прокси-метрику +качества WeSpeaker. + +Двуязычная `3dspeaker_speech_campplus_sv_zh_en_16k-common_advanced.onnx` +быстрее и при известном числе участников лучше на одной из двух записей с +таймкодами, но для автоматического режима не нашлось общего порога без лишних +кластеров или склейки реальных голосов. Поэтому она не выбрана по умолчанию. + +## Что проверялось + +Свип выполнялся на трёх русскоязычных рабочих созвонах с известным составом: + +| Запись | Участников | Короткий фрагмент | Полный прогон кандидата | SHA-256 | +|---|---:|---:|---:|---| +| `2026-07-10 Data Test внутренний статус.mp4` | 3 | 07:00–12:00 | 25:59,9 | `1057616B42E8ADD00E0EB975B02BDEF0EC9F6CDFEC6DBF488E0C60423C9B7B87` | +| `2026-07-29 T2 BDMA уточнение задачи от Ильи.mp4` | 2 | 00:00–05:00 | 14:50,9 | `51866D247FE3EDA134CDD884F707B1F1DB8855B492E8BD14D2B56B62476255ED` | +| `2026-08-12 Созвон с Максом Мерлином по T2 Forecast и Yantar.mp4` | 2 | 00:00–05:00 | 20:22,2 | `4422F04E2771091A0648E5422D14A31DD7CA2C8EEF7ED9F4A5C63F43D8CA6400` | + +Для первой записи число участников взято из согласованного MoM и не зависит от +диаризации. Для двух остальных рядом с медиа лежат транскрипты Hypescribe с +таймкодами и метками спикеров. Они получены другим инструментом и использованы +как независимая грубая опорная разметка. + +Hypescribe ставит метку только в начале реплики и не размечает точные границы, +тишину и наложения голосов. Поэтому ниже считается не DER, а **mapped speaker +purity**: лучший взаимно-однозначный маппинг кластеров на опорные метки по +суммарному перекрытию. Метрика подходит для сравнения конфигураций на одной +записи, но не является абсолютной оценкой диаризации. + +Кластер считается содержательным, если в нём не меньше `max(5 с, 2% длины +записи)` речи. Это только диагностический показатель: готовый CLI не должен +молча отбрасывать малые кластеры без отдельного решения. + +## Модели + +Во всех прогонах использовалась одна сегментация +`sherpa-onnx-pyannote-segmentation-3-0`. + +| Роль | Модель | Языки обучения | Размер | SHA-256 | +|---|---|---|---:|---| +| выбранная | `wespeaker_en_voxceleb_resnet34_LM.onnx` | английский, VoxCeleb2 | 26 530 550 | `E9848563DA86F263117134DFD7AD63C92355B37DE492B55E325400C9D9C39012` | +| многоязычная альтернатива | `3dspeaker_speech_campplus_sv_zh_en_16k-common_advanced.onnx` | китайский + английский | 28 281 164 | `AA3CFC16963A10586A9393F5035D6D6B57E98D358B347F80C2A30BF4F00CEBA2` | +| дополнительная разведка | `3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx` | китайский | 39 593 761 | `1A331345F04805BADBB495C775A6DDFFCDD1A732567D5EC8B3D5749E3C7A5E4B` | +| сегментация | `model.onnx` из `sherpa-onnx-pyannote-segmentation-3-0` | — | 5 992 913 | `220AD67CA923BEF2FA91F2390C786097BF305BCEB5E261D4AF67B38E938E1079` | + +WeSpeaker сам помечает VoxCeleb-модель как английскую и распространяет её под +CC BY 4.0. Репозиторий 3D-Speaker и модель CAMPPlus на ModelScope используют +Apache 2.0; в исходниках 3D-Speaker модель явно описана как обученная на +большом китайско-английском корпусе. ONNX-файлы брались из официального релиза +`k2-fsa/sherpa-onnx`, а не из сторонних зеркал. + +Источники: + +- [список и лицензирование моделей WeSpeaker](https://github.com/wenet-e2e/wespeaker/blob/master/docs/pretrained.md); +- [карточка `wespeaker-voxceleb-resnet34-LM`](https://huggingface.co/Wespeaker/wespeaker-voxceleb-resnet34-LM); +- [исходники и лицензия 3D-Speaker](https://github.com/modelscope/3D-Speaker); +- [официальный релиз ONNX-моделей sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx/releases/tag/speaker-recongition-models). + +## Свип WeSpeaker + +Порог сначала проверялся крупным шагом, затем уточнялся около переходов между +числом кластеров. В ячейках — общее число кластеров; жирным выделено точное +совпадение с известным числом участников. + +| Порог | Data Test, 3 | T2 BDMA, 2 | Yantar, 2 | +|---:|---:|---:|---:| +| 0,85 | **3** | **2** | 3 | +| 0,87 | **3** | **2** | 3 | +| 0,88 | **3** | **2** | 3 | +| **0,89** | **3** | **2** | **2** | +| 0,90 | 2 | **2** | **2** | +| 0,95 | 2 | **2** | 1 | +| явное `num_clusters` | **3** | **2** | **2** | + +`0,89` — единственное проверенное значение, которое без знания числа +участников дало правильное количество кластеров на всех трёх фрагментах. На +двух записях с опорными метками purity составила 0,767 и 0,787. Явное число +участников дало те же значения. + +## Сравнение с 3D-Speaker + +### CAMPPlus, китайский + английский + +| Порог | Data Test, 3 | T2 BDMA, 2 | Yantar, 2 | +|---:|---:|---:|---:| +| 0,85 | 7 | 7 | 7 | +| 0,90 | 6 | 5 | 5 | +| 0,95 | 5 | 5 | 5 | +| 0,99 | 4 | 4 | 4 | +| 1,00 | 4 | 4 | 4 | +| 1,05 | **3** | 3 | 3 | +| 1,10 | 2 | **2** | 3 | +| явное `num_clusters` | **3** | **2** | **2** | + +При `1,05` на двух записях остаётся по одному малому остаточному кластеру, а +при `1,10` трёхсторонняя встреча уже склеивается до двух голосов. Общего +автоматического порога нет. + +При явном числе участников purity равна 0,801 на T2 BDMA и 0,911 на Yantar. +Это лучше WeSpeaker на 0,034 и 0,124 соответственно. Однако на контрольной +трёхсторонней записи один из трёх принудительных кластеров оказался меньше +порога содержательности, поэтому улучшение по двум текстовым прокси нельзя +обобщать на все записи. + +### ERes2Net base, китайский + +Эта модель проверялась дополнительно, но не считается выполнением требования +о многоязычной альтернативе. Даже на пороге 0,99 она дала 5 / 4 / 7 кластеров +вместо 3 / 2 / 2. При явном числе участников purity составила 0,688 и 0,907: +результат неоднородный и автоматический режим заметно хуже выбранного. + +## Полные прогоны выбранного кандидата + +После свипа `WeSpeaker + 0,89` прогнан на всех трёх записях целиком. + +| Запись | Кластеры | Содержательные | Речь по кластерам, с | Остаток сверх ожидаемых | Purity | Время | RTF | +|---|---:|---:|---|---:|---:|---:|---:| +| Data Test | 4 | 3 | 573,1 / 487,4 / 342,2 / 19,1 | 1,3% | — | 189,0 с | 0,121 | +| T2 BDMA | 2 | 2 | 748,7 / 52,3 | 0% | 0,749 | 112,3 с | 0,126 | +| Yantar | 2 | 2 | 733,2 / 259,8 | 0% | 0,906 | 151,4 с | 0,124 | + +На полной контрольной записи остаётся ложный кластер на 19,1 с, но три +содержательных кластера совпадают с известным составом. Повторный полный прогон +на 0,9 дал тот же результат: JSON-массивы всех 340 интервалов на 0,89 и 0,9 +совпали в точности, включая границы и номера кластеров. Поэтому к кандидату +0,89 непосредственно применима слуховая проверка отрезка 07:00–12:00, +выполненная для результата из +[разведочного замера](2026-08-12-diarization-feasibility.md): три основных +голоса стабильны, остаточный кластер ложный, есть небольшие пропуски второго +голоса, а наложения голосов определяются не полностью. Новая калибровка не +устраняет эти ограничения сегментации. + +На двух полных разговорах purity отличается от короткого фрагмента: 0,749 +против 0,767 и 0,906 против 0,787. Это подтверждает, что короткий свип годится +для отсева конфигураций, а финальный кандидат надо проверять целиком. + +## Производительность + +Условия: AMD Ryzen 7 8845H, Windows 11 build 26200, Python 3.13.13, +`sherpa-onnx` 1.13.5, `onnxruntime` 1.28.0, NumPy 2.4.3, 8 потоков CPU. +Загрузка моделей и декодирование медиа не входят в измерение. + +Средний RTF на коротких фрагментах: + +| Модель | RTF | Относительно WeSpeaker | +|---|---:|---:| +| WeSpeaker ResNet34 LM | 0,118 | 1,00× | +| CAMPPlus zh/en | 0,083 | 0,70× | +| ERes2Net base zh | 0,152 | 1,29× | + +CAMPPlus примерно на 30% быстрее WeSpeaker в этом эксперименте. Это плюс для +варианта с известным числом участников, но замер на AMD не заменяет приёмку на +целевом Intel Core i5 11-го поколения. + +## Воспроизводимость + +Свип выполняется скриптом +[`scripts/benchmarks/diarization_calibration.py`](../../scripts/benchmarks/diarization_calibration.py). +Он принимает JSON-манифест с путями к моделям и записям, декодирует указанные +фрагменты через ffmpeg, последовательно сохраняет каждый результат и может +возобновить прерванный прогон. + +Пример: + +```powershell +uv run python scripts/benchmarks/diarization_calibration.py ` + --manifest diarization-calibration.json ` + --output diarization-calibration-results.json ` + --work-dir .scratch/diarization-calibration ` + --threads 8 +``` + +Сырые JSON содержат локальные пути к конфиденциальным рабочим записям и сами +интервалы диаризации, поэтому в репозиторий не добавляются. Для проверки +артефактов выше приведены SHA-256 медиа и моделей. + +## Ограничения и следующий шаг + +- Три записи принадлежат одному типу русскоязычных рабочих созвонов; это не + репрезентативная выборка для всех микрофонов, шумов и акцентов. +- Опорные метки двух записей грубые и не дают посчитать DER. +- На слух проверен только фрагмент 07:00–12:00 контрольной записи; перед + выпуском нужен слуховой контроль плотного диалога на финальной сборке. +- Калибровка выбирает эмбеддинги и кластеризацию, но не решает ошибки границ и + неполное распознавание наложений голосов. +- Производительность должна отдельно приниматься на целевом Intel Core i5. + +Для спецификации зафиксировать WeSpeaker + 0,89 как автоматический дефолт, +отдельную опцию явного числа участников и отсутствие автоматического +отбрасывания малых кластеров. CAMPPlus zh/en можно оставить кандидатом для +будущего режима с обязательным `num_clusters` после расширенной слуховой +проверки. diff --git a/scripts/benchmarks/diarization_calibration.py b/scripts/benchmarks/diarization_calibration.py new file mode 100644 index 0000000..150af33 --- /dev/null +++ b/scripts/benchmarks/diarization_calibration.py @@ -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()