docs(diarization): собраны исследования и калибровка для карты #18

Merged
ddmitry merged 15 commits from feature/8-diarization-map into master 2026-08-14 17:19:15 +03:00
2 changed files with 564 additions and 0 deletions
Showing only changes of commit 0fdeebb256 - Show all commits
@@ -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:0012:00 | 25:59,9 | `1057616B42E8ADD00E0EB975B02BDEF0EC9F6CDFEC6DBF488E0C60423C9B7B87` |
| `2026-07-29 T2 BDMA уточнение задачи от Ильи.mp4` | 2 | 00:0005:00 | 14:50,9 | `51866D247FE3EDA134CDD884F707B1F1DB8855B492E8BD14D2B56B62476255ED` |
| `2026-08-12 Созвон с Максом Мерлином по T2 Forecast и Yantar.mp4` | 2 | 00:0005: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` после расширенной слуховой
проверки.
@@ -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()