docs(diarization): зафиксированы результаты приёмки и анализа

- Зачем:
  - решения по неопределённым словам и CPU-стоимости должны быть проверяемыми до публикации реализации.
- Что:
  - задокументировано отклонение post-hoc сглаживания Speaker ? по соседним кластерам.
  - добавлено исследование стоимости каскада и облегчённых embedding-моделей.
- Проверка:
  - `git diff --cached --check` — без ошибок.
This commit is contained in:
Dmitriy Dementiev
2026-08-14 21:56:10 +03:00
parent 726718197f
commit 98baf55572
2 changed files with 233 additions and 0 deletions
@@ -45,6 +45,39 @@ WeSpeaker ResNet34 LM проверена на трёх записях с изв
[смешении говорящих](../benchmarks/2026-08-14-asr-segment-speaker-mixing.md) и [смешении говорящих](../benchmarks/2026-08-14-asr-segment-speaker-mixing.md) и
[производительности на Intel](../benchmarks/2026-08-14-diarization-intel-i7.md). [производительности на Intel](../benchmarks/2026-08-14-diarization-intel-i7.md).
### Сглаживание неизвестного говорящего
После приёмки отдельно проверена идея автоматически назначать `Speaker ?`
известному говорящему, если короткий неизвестный фрагмент находится между двумя
репликами одного и того же `Speaker N`. На трёх контрольных транскриптах найдено
143 таких неизвестных фрагмента, содержащих 309 слов. У 79 фрагментов соседи
имели одинаковую метку, у 64 — разные.
Для T2 BDMA и Yantar результат грубо сопоставлен с независимыми Hypescribe-
транскриптами. Это не gold-разметка: их границы округлены до секунды, а готовый
speaker Markdown хранит только начало реплики. Тем не менее эвристика не показала
достаточной селективности:
| Эвристика | Изменённых фрагментов на трёх записях | Совпадение на двух записях с опорой |
|---|---:|---:|
| Все неизвестные между одинаковыми соседями | 79 | 22 / 42 (52%) |
| Только один неизвестный word | 35 | 11 / 20 (55%) |
| Не больше двух words | 50 | 15 / 30 (50%) |
| Одинаковый отображённый timestamp | 42 | 14 / 22 (64%) |
Короткие `Да`, `Нет` и `Угу` часто являются самостоятельной репликой другого
участника. В одном из контрольных случаев неизвестный фрагмент `— Угу. — А`
даже содержал границу двух голосов, хотя с обеих сторон находился один и тот же
кластер. Поэтому post-hoc сглаживание по соседям скрывает полезную
неопределённость и не применяется. `Speaker ?` остаётся явным результатом ничьей
или отсутствия временного перекрытия.
Если к этой задаче возвращаться, проверять нужно исходные границы `Word` и
`SpeakerInterval` до группировки: отдельно различать нулевой timestamp, реальный
зазор между интервалами и ничью перекрытий. По готовому Markdown такая проверка
невозможна, потому что в нём уже потеряны доли секунды, конец слова и причина
неопределённости.
## Решение ## Решение
Диаризацию реализуем как явно включаемый пост-процессинг через `sherpa-onnx`. Диаризацию реализуем как явно включаемый пост-процессинг через `sherpa-onnx`.
@@ -97,3 +130,4 @@ ASR-бэкенд приводит свой результат к общему н
| `pyannote.audio` | Тянет PyTorch и требует Hugging Face token с принятием лицензии | | `pyannote.audio` | Тянет PyTorch и требует Hugging Face token с принятием лицензии |
| Сборка поверх приватных деталей `onnx-asr` | Экономит небольшую отдельную зависимость ценой нестабильного внутреннего API и собственной кластеризации | | Сборка поверх приватных деталей `onnx-asr` | Экономит небольшую отдельную зависимость ценой нестабильного внутреннего API и собственной кластеризации |
| Параллельные проходы в первой версии | Нет прямого benchmark и измеренного общего пика памяти; сначала нужен корректный последовательный baseline | | Параллельные проходы в первой версии | Нет прямого benchmark и измеренного общего пика памяти; сначала нужен корректный последовательный baseline |
| Автоматически назначать `Speaker ?` одинаковому соседнему кластеру | На двух записях с независимой опорой совпало только 52% назначений; лимиты по словам и округлённому времени не отделили короткие ответы другого участника |
@@ -0,0 +1,199 @@
# Стоимость CPU-диаризации и облегчённые альтернативы
**Дата:** 2026-08-14
**Статус:** исследовательская записка. Production-конфигурация не менялась.
## Краткий вывод
Измеренная стоимость ожидаема для выбранного каскада, но не является
неизбежным минимумом. `WeSpeaker ResNet34 LM` — не аномально большая модель:
ONNX-файл занимает 26,5 МБ, а сама архитектура находится в младшей части
семейства WeSpeaker ResNet. Основная цена возникает из способа применения
моделей: Pyannote проходит запись перекрывающимися 10-секундными окнами с шагом
1 секунда, после чего sherpa-onnx отдельно считает speaker embedding для каждого
локального говорящего каждого окна.
На минутном профиле текущей связки на этом ноутбуке эмбеддинги заняли 5,150 с
из 6,395 с, сегментация — 1,243 с, кластеризация — меньше измеримой миллисекунды.
То есть около 81% времени в этом прогоне пришлось на многократные вызовы
WeSpeaker. Ускорять только кластеризацию или менять её порог бессмысленно.
Более лёгкие пути есть. Самый безопасный для исследования — официальная INT8
версия той же Pyannote-сегментации. Самый большой подтверждённый upstream
потенциал даёт `nemo_en_titanet_small`, но её качество и порог кластеризации на
русских созвонах проекта ещё не проверялись. Локально проверенная CAMPPlus zh/en
быстрее WeSpeaker примерно на 30%, однако не прошла требование автоматического
определения числа говорящих с единым порогом.
## Откуда берётся стоимость
`sherpa-onnx` строит результат из трёх вычислительных стадий: speaker
segmentation, speaker embeddings и clustering. Это соответствует как
[официальному API](https://k2-fsa.github.io/sherpa/onnx/c-api/html/speaker_diarization.html),
так и [реализации конвейера
Pyannote](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h).
### 1. Перекрывающаяся сегментация
Pyannote segmentation 3.0 принимает 10 секунд mono 16 кГц и различает до трёх
локальных говорящих в окне, включая пары одновременно говорящих. Это явно
зафиксировано в [карточке исходной
модели](https://huggingface.co/pyannote/segmentation-3.0).
В официальном ONNX-файле `window_size=160000`, то есть те же 10 секунд при
16 кГц. В sherpa-onnx 1.13.5 `window_shift_ratio` по умолчанию равен 0,1, а
число окон вычисляется из размера окна и этого шага. Поэтому обычная длинная
запись проходит через segmentation-модель примерно десятикратно
перекрывающимися окнами: новое окно начинается каждую секунду. См.
[конфигурацию шага](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-segmentation-pyannote-model-config.h)
и [цикл обработки
окон](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h).
Это не признак «огромной» модели: FP32-файл сегментации весит лишь 5,7 МБ.
Стоимость создаёт прежде всего частота её запуска. Официальная поставка также
содержит `model.int8.onnx` размером 1,5 МБ
([документация sherpa-onnx](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html)).
### 2. Embedding для каждого локального говорящего каждого окна
После сегментации sherpa-onnx исключает перекрывающиеся кадры из материала для
эмбеддинга, собирает пары `(окно, локальный говорящий)` и для каждой пары создаёт
отдельный stream и запускает embedding extractor. Это видно непосредственно в
[методах `GetChunkSpeakerSampleIndexes` и
`ComputeEmbeddings`](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h).
При одном активном голосе это уже примерно один embedding на секунду записи;
если в окне модель видит несколько локальных голосов, вызовов становится больше.
Текущий extractor — английский VoxCeleb `ResNet34_LM`; WeSpeaker поясняет, что
суффикс LM означает дополнительную large-margin донастройку, полезную на
фрагментах длиннее трёх секунд
([официальный список моделей](https://github.com/wenet-e2e/wespeaker/blob/master/docs/pretrained.md)).
### 3. Кластеризация почти бесплатна
В [первичном разведочном
замере](../benchmarks/2026-08-12-diarization-feasibility.md) смена порога не
изменила время: 153–157 с, а кластеризация занимала доли секунды. Минутный профиль
текущей production-конфигурации также показал `0.000 s` для clustering. Явное
число участников полезно для качества и стабильности количества кластеров, но не
является существенной оптимизацией CPU.
## Что показывают локальные измерения
| Среда и материал | Конфигурация | Результат |
|---|---|---:|
| Ryzen 7 8845H, Data Test 26:00 | Pyannote FP32 + WeSpeaker, 8 потоков | 140,7 с; 11,1× realtime |
| Ryzen 7 8845H, три 5-минутных фрагмента | WeSpeaker | средний RTF 0,118 |
| Ryzen 7 8845H, те же фрагменты | CAMPPlus zh/en | средний RTF 0,083; 0,70× от WeSpeaker |
| Intel Core i7-6820HQ, 61:13 аудио | WeSpeaker, 8 потоков | 832,3 с; 4,4× realtime |
| Текущий ноутбук, 60 с T2 BDMA | WeSpeaker, 8 потоков, debug profile | segmentation 1,243 с; embeddings 5,150 с; clustering 0,000 с; всего 6,395 с |
Источники полных воспроизводимых замеров: [разведка на
Ryzen](../benchmarks/2026-08-12-diarization-feasibility.md), [калибровка
эмбеддингов](../benchmarks/2026-08-14-diarization-calibration.md) и [Intel
baseline](../benchmarks/2026-08-14-diarization-intel-i7.md). Минутный профиль —
диагностический одиночный прогон той же production-конфигурации на первых 60
секундах T2 BDMA; его следует использовать для распределения стоимости по
стадиям, а не как новый общий benchmark.
Увеличение `num_threads` не решает проблему. На Data Test переход с четырёх
потоков (154 с) на восемь (141 с) дал только 9%. Это согласуется с устройством
конвейера: он выполняет много последовательных ONNX-вызовов для отдельных окон
и локальных говорящих, поэтому добавление потоков внутри одного вызова быстро
перестаёт масштабироваться.
## Официально поддерживаемые облегчённые варианты
### INT8 Pyannote segmentation 3.0
Sherpa-onnx официально поставляет FP32 и INT8 варианты одной сегментации. На его
контрольной записи замена только segmentation-модели при 3D-Speaker embedding
снизила RTF с 0,297 до 0,241, то есть примерно на 19%. С TitaNet small разница
меньше: 0,119 против 0,110. Все числа опубликованы на одной странице
[официальных примеров и
замеров](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html).
Trade-off: это минимальное архитектурное изменение, но INT8 меняет границы
интервалов даже в официальном примере. Перед заменой нужно повторить на трёх
контрольных записях число кластеров, purity, малые кластеры, неназначенные слова
и полный runtime. На текущем минутном профиле segmentation занимает лишь около
19% времени, поэтому одной квантизацией нельзя ожидать кратного ускорения всей
диаризации.
### 3D-Speaker CAMPPlus zh/en
Модель уже входит в официальный релиз speaker-recognition моделей sherpa-onnx,
а 3D-Speaker публикует CAM++ как штатную архитектуру своего набора
([репозиторий и таблица
моделей](https://github.com/modelscope/3D-Speaker)). Её ONNX-файл занимает
28,3 МБ — немного больше текущих 26,5 МБ, поэтому размер файла здесь плохо
предсказывает вычислительную стоимость.
Локально CAMPPlus дала RTF 0,083 против 0,118 у WeSpeaker, то есть была примерно
на 30% быстрее. При известном числе участников она также улучшила proxy-purity
на двух разговорах. Но в автоматическом режиме не нашлось единого порога: один
порог оставлял лишние кластеры, следующий уже склеивал реальные голоса. Полные
данные находятся в [отчёте о
калибровке](../benchmarks/2026-08-14-diarization-calibration.md).
Trade-off: хороший кандидат для режима с обязательным `--speakers N`, но не
готовая замена общего автоматического режима.
### NeMo TitaNet small
Sherpa-onnx официально показывает `nemo_en_titanet_small` как совместимый
embedding extractor. На его контрольной записи Pyannote FP32 + TitaNet small
дала RTF 0,119 вместо 0,297 у Pyannote FP32 + 3D-Speaker ERes2Net; с INT8
сегментацией — 0,110 вместо 0,241. Это самый большой опубликованный upstream
выигрыш среди проверенных на одной странице комбинаций
([официальные замеры](https://k2-fsa.github.io/sherpa/onnx/speaker-diarization/models.html)).
TitaNet использует 1D depth-wise separable convolutions и channel-attention
statistics pooling
([документация NVIDIA](https://docs.nvidia.com/nemo-framework/user-guide/25.02/nemotoolkit/asr/speaker_recognition/models.html)).
Trade-off: upstream-цифры сняты на другой embedding-модели сравнения, другом
материале и оборудовании, поэтому коэффициент нельзя переносить на этот ноутбук.
Кроме того, TitaNet small не проходила локальную калибровку на русской речи:
неизвестны подходящий clustering threshold, стабильность числа голосов и
качество коротких реплик. Сначала нужен тот же свип, который уже выполнен для
WeSpeaker и CAMPPlus.
### Увеличение шага окна
В C++-конфигурации sherpa-onnx 1.13.5 есть `window_shift_ratio`, поэтому на уровне
движка можно уменьшить число перекрывающихся окон ценой более грубой разметки.
Но [Python binding этой
версии](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/offline-speaker-diarization.cc)
экспортирует только путь `model`, а не `window_shift_ratio`. Для текущего Python
приложения это не штатная ручка без изменения upstream binding или собственного
нативного слоя. Даже после появления ручки потребуется отдельная калибровка:
более редкие окна могут ухудшить границы смены голоса и короткие ответы — как раз
самую чувствительную часть текущего результата.
## Практические следующие шаги
1. Не считать текущую стоимость дефектом реализации: выбранный результат
соответствует устройству sherpa-onnx и остаётся быстрее realtime даже на
старом Intel.
2. Первым отдельным экспериментом прогнать INT8 Pyannote на тех же трёх файлах.
Это наименьший по масштабу вариант, хотя ожидаемый выигрыш умеренный.
3. Отдельно откалибровать TitaNet small. У неё лучший опубликованный потенциал
скорости, но пока нет локальных данных о качестве.
4. CAMPPlus предлагать только как кандидат для режима с известным числом
участников, если 30% экономии оправдывает второй production-профиль.
5. Параллельный ASR и diarization исследовать независимо от выбора модели. Он не
уменьшает CPU-работу, но может сократить wall-clock latency. Обязательно
измерить конкуренцию за ядра и общий peak RSS: на Intel последовательные пики
составляли 900–1035 МБ для ASR и 366–469 МБ для диаризации, а совместный пик
пока не измерен.
## Вердикт
Текущая диаризация тяжёлая **в основном из-за каскада и плотного перекрытия
окон**, а не потому, что случайно выбрана гигантская модель. Но выбранный
WeSpeaker ResNet34 LM не самый быстрый extractor. Реалистичный резерв — умеренное
ускорение через INT8 segmentation, около 30% по локальным данным через CAMPPlus
при известном числе участников и потенциально более крупное ускорение через
TitaNet small после обязательной русскоязычной калибровки. Простое добавление
потоков или настройка clustering заметного выигрыша не даст.