From bcf264e0aa416a3c2ee2d73aa5c9f9e211aeb01a Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 30 Aug 2026 21:04:03 +0300 Subject: [PATCH] =?UTF-8?q?docs(research):=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=B2?= =?UTF-8?q?=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D0=B0=D1=8F=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - Исследование числа говорящих не проходило отдельный Wayfinder-тикет и не должно публиковаться как принятое решение. - Что: - Записка об оценке числа говорящих удалена из publication-ветки. - Проверка: - `git diff --check`. --- .../2026-08-30-diarization-speaker-count.md | 281 ------------------ 1 file changed, 281 deletions(-) delete mode 100644 docs/research/2026-08-30-diarization-speaker-count.md diff --git a/docs/research/2026-08-30-diarization-speaker-count.md b/docs/research/2026-08-30-diarization-speaker-count.md deleted file mode 100644 index 4fe3bb0..0000000 --- a/docs/research/2026-08-30-diarization-speaker-count.md +++ /dev/null @@ -1,281 +0,0 @@ -# Оценка числа говорящих для быстрых эмбеддингов - -**Дата:** 2026-08-30 - -**Статус:** исследовательская записка. Production-код и конфигурация не -менялись. - -## Вопрос - -Как отделить оценку числа говорящих от порога `FastClustering`, чтобы -использовать быстрые CAMPPlus или TitaNet и не запускать сегментацию и -извлечение эмбеддингов второй раз? - -## Краткий вывод - -Нужна двухшаговая схема: один раз получить ровно те эмбеддинги, которые -подготовил конвейер sherpa-onnx, отдельно оценить по ним число голосовых -кластеров `N`, затем передать `N` штатному `FastClustering`. Для первого -прототипа рекомендован **только счетчик NME**, без полной spectral clustering: -ядро алгоритма из статьи реализовать на уже доступном NumPy, а итоговые метки -по-прежнему получить через `FastClustering(num_clusters=N)`. Простой eigengap с -фиксированным прореживанием нужен как дешевый контрольный вариант. - -Публичного промежуточного интерфейса для этого в sherpa-onnx 1.13.5 нет. -`SpeakerEmbeddingExtractor` и `FastClustering` публичны, но -`OfflineSpeakerDiarization.process()` возвращает только готовые интервалы. -Матрица эмбеддингов и соответствие строк локальным говорящим остаются внутри -реализации. После `set_config()` повторный `process()` снова выполнит модельный -инференс. Python-адаптер проекта сам по себе не может дать дешевую повторную -кластеризацию; будущему прототипу нужен узкий исследовательский интерфейс на -уровне sherpa-onnx. - -## Что подтверждено про sherpa-onnx - -### `FastClustering` уже умеет второй шаг - -В версии 1.13.5 алгоритм нормирует строки, строит все попарные cosine -dissimilarity, выполняет complete-linkage AHC и затем выбирает один из двух -способов разрезать одно и то же дерево: - -- при `num_clusters > 0` - ровно на заданное число кластеров; -- иначе - по высоте `threshold`. - -Это видно непосредственно в -[`fast-clustering.cc`](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/fast-clustering.cc) -и закреплено в комментарии к -[`FastClusteringConfig`](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/fast-clustering-config.h): -при известном числе кластеров порог игнорируется. Python binding публично -принимает C-contiguous матрицу `float32` размера `M x D` и возвращает метку для -каждой строки -([исходник binding](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/fast-clustering.cc)). -Значит, передавать оцененное `N` в существующий кластеризатор не требует новой -реализации распределения эмбеддингов по кластерам. - -Порог `FastClustering` не принадлежит модели эмбеддингов. Это высота -complete-linkage дерева в шкале cosine dissimilarity. Официальный пример -CAMPPlus для speaker verification лишь вычисляет cosine score и не задает -универсальной границы решения -([`infer_sv.py`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/bin/infer_sv.py)). -Официальный diarization-рецепт 3D-Speaker отдельно задает `pval=0.012` для -spectral clustering и `mer_cos=0.8` для последующего слияния центров -([`infer_diarization.py`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/bin/infer_diarization.py)). -Эти величины имеют другой смысл и не могут быть перенесены в sherpa как -`порог модели`. - -### Публичных деталей недостаточно для повтора всего конвейера - -Публичный `SpeakerEmbeddingExtractor` позволяет создать stream, подать в него -аудио и получить один embedding -([Python binding](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/speaker-embedding-extractor.cc), -[C/C++ API](https://k2-fsa.github.io/sherpa/onnx/c-api/html/speaker_embedding.html)). -Это пригодно для самостоятельно выбранного фрагмента, но не выдает материал, -который выбрал diarization-конвейер. - -Внутри `OfflineSpeakerDiarization` после сегментации: - -1. из перекрывающихся окон строятся пары `окно x локальный говорящий`; -2. речь с одновременными голосами исключается; -3. для каждой пары собирается свой stream и вычисляется embedding; -4. вся матрица передается в `FastClustering`; -5. метки строк переводятся обратно в разметку говорящих. - -Последовательность и временная матрица видны в -[`OfflineSpeakerDiarizationPyannoteImpl::Process`](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h#L87-L204), -а сборка строк - в -[`GetChunkSpeakerSampleIndexes` и `ComputeEmbeddings`](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h#L376-L528). -Это private-методы реализации. - -Python-класс `OfflineSpeakerDiarization` экспортирует только `sample_rate`, -`set_config` и `process`; `process` возвращает готовый результат, а не признаки -([binding 1.13.5](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/offline-speaker-diarization.cc#L73-L128)). -Внутренний `SetConfig` заменяет только объект кластеризации, тогда как каждый -`Process` заново вызывает segmentation и `ComputeEmbeddings` -([реализация](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/offline-speaker-diarization-pyannote-impl.h#L79-L175)). -Актуальная публичная C API также документирует только создание, настройку и -получение итоговых сегментов -([официальная страница](https://k2-fsa.github.io/sherpa/onnx/c-api/html/speaker_diarization.html)). - -Итог: публичный API позволяет **кластеризовать имеющуюся матрицу повторно**. -Извлечь или переиграть промежуточную матрицу штатного diarization-конвейера -через него нельзя. - -## Варианты оценки `N` - -### 1. Отдельный порог для каждой embedding-модели - -Это текущий `FastClustering` в автоматическом режиме. Он дешев, не добавляет -зависимостей и на одном проходе сразу оценивает число кластеров и назначает -метки. На порог влияют архитектура модели эмбеддингов, длительность и состав -выбранных фрагментов, акустика и распределение голосов. Локальная калибровка -уже не нашла общего рабочего порога для CAMPPlus, -а TitaNet не прошел автоматическую сетку -([исходное исследование](2026-08-14-diarization-cpu-cost.md), -[повторный CPU-тест](../benchmarks/2026-08-30-lightweight-diarization-cpu.md)). - -Вводить еще один `официальный порог модели` оснований нет: upstream-рецепты -публикуют параметры конкретного clustering pipeline, а не переносимую границу -для complete-linkage sherpa. Вариант остается контрольным, но не решает тикет. - -### 2. Spectral clustering с простым eigengap - -Официальный CAMPPlus-конвейер 3D-Speaker строит cosine affinity, оставляет в -каждой строке фиксированную долю ближайших соседей, симметризует матрицу, -вычисляет ненормированный Laplacian и несколько наименьших собственных пар. -Число говорящих - позиция максимального eigengap в заданных пределах, после -чего собственные векторы кластеризуются k-means -([`SpectralCluster`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/process/cluster.py#L21-L106)). - -Для нашей двухшаговой схемы нужны только собственные значения и оценка `N`; -k-means можно не выполнять. Это одна spectral decomposition и потому хороший -минимальный контрольный вариант. Однако фиксированный `pval` остается новым -порогом. -Официальная реализация задает его для своего способа нарезки; пары -`окно x локальный говорящий` sherpa устроены иначе. Вариант отделяет `N` от -порога AHC, но сохраняет калибруемый параметр. - -Если брать реализацию 3D-Speaker как зависимость, она импортирует NumPy, SciPy, -scikit-learn, fastcluster, UMAP и HDBSCAN, причем последние два обязательны уже -при импорте модуля -([исходник](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/process/cluster.py#L3-L18), -[requirements](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/requirements.txt)). -Для production это несоразмерно счетчику. - -### 3. NME-SC - -NME-SC создан именно для совместной оценки числа кластеров и параметра -прореживания без dev-set. Для каждого числа соседей `p` алгоритм бинаризует -cosine affinity, симметризует ее, строит ненормированный Laplacian и считает -eigengap. Нормированный gap равен максимальному gap, деленному на наибольшее -собственное значение; выбирается `p`, минимизирующий отношение `p / gap`, а `N` -задает позиция максимального gap -([статья авторов, раздел III](https://arxiv.org/abs/2003.02405)). -Авторы ограничивали поиск `p` диапазоном до четверти числа фрагментов и число -говорящих - восемью; результаты статьи получены на x-vector и англоязычных -корпусах, поэтому перенос на наши эмбеддинги еще предстоит проверить. - -Поддерживаемая реализация NeMo принимает готовые эмбеддинги, строит cosine -affinity и отдельно получает `est_num_of_spk`; известное число говорящих затем -подменяет эту оценку перед spectral clustering -([`offline_clustering.py`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L1087-L1161)). -Для ускорения NME она по умолчанию ограничивает матрицу анализа 512 строками, -перебирает разреженную сетку из 30 значений `p` и предупреждает, что меньше 20 -может ухудшить оценку -([класс `NMESC`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L801-L958)). -Для коротких записей NeMo добавляет случайные anchor embeddings и повторяет -оценку. Это отдельное расширение за пределами минимального ядра статьи -([исходник](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L536-L595)). - -Полная NME-SC после оценки `N` еще раз раскладывает Laplacian, строит spectral -embeddings и запускает k-means. Для этого тикета такая замена итогового -кластеризатора не нужна: локальные тесты уже показывают полезный путь -`FastClustering` с известным числом говорящих. Нужна только функция -`embeddings -> N`. - -### 4. Оценить `N`, затем передать известное число кластеров - -Это не отдельный счетчик. Это рекомендуемая граница ответственности: - -```text -segmentation -> embeddings +-> count-only NME -> N - +-> FastClustering(features, num_clusters=N) - -> штатная финализация sherpa -``` - -Плюсы: - -- порог complete-linkage больше не определяет число голосовых кластеров; -- CAMPPlus и TitaNet используют один модельный проход; -- итоговое назначение меток остается в уже принятом FastClustering; -- на одной матрице можно независимо сравнить несколько счетчиков. - -Минус один, но архитектурный: без нового промежуточного интерфейса текущий -`process()` не позволяет вставить счетчик между вычислением матрицы и -кластеризацией. - -## Зависимости, лицензии и цена - -Штатная установка NeMo требует среди прочего NumPy, scikit-learn и PyTorch -([`pyproject.toml`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/pyproject.toml)). - -| Вариант | Новые runtime-зависимости | Дополнительная работа после эмбеддингов | Ограничение | -|---|---|---|---| -| Порог `FastClustering` | нет | уже существующие попарные расстояния и AHC | порог калибруется для модели и материала | -| Простой eigengap | SciPy в официальной реализации; либо только уже доступный NumPy | affinity `M x M`, одно собственное разложение | фиксированный параметр прореживания | -| Полная NME-SC NeMo | `nemo-toolkit`, PyTorch, scikit-learn и другие зависимости NeMo | до нескольких десятков eigenvalue-разложений, затем еще spectral embedding и k-means | самый тяжелый и меняет итоговый кластеризатор | -| Счетчик NME + FastClustering | только NumPy при чистой реализации ядра статьи | NME на ограниченной матрице, затем штатный AHC по известному `N` | нужен промежуточный интерфейс и локальная проверка переноса | - -`FastClustering` уже хранит `M(M-1)/2` расстояний и строит AHC. Простой -eigengap добавляет еще одну плотную affinity-матрицу. NME повторяет eigenvalue- -анализ для набора `p`; ограничение размера и разреженный поиск контролируют -цену, но их влияние на качество нужно измерить. Полная spectral clustering -также хранит собственные векторы и запускает k-means, поэтому для одной оценки -`N` избыточна. В проектных замерах основной расход по-прежнему приходится на -извлечение эмбеддингов, а не на clustering -([профиль стоимости](2026-08-14-diarization-cpu-cost.md), -[CPU-тест](../benchmarks/2026-08-30-lightweight-diarization-cpu.md)); это не -заменяет отдельного замера NME на целевой матрице. - -sherpa-onnx, 3D-Speaker и NeMo Speech распространяют код под Apache-2.0 -([sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/LICENSE), -[3D-Speaker](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/LICENSE), -[NeMo Speech](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/LICENSE)). -Авторская реализация NME-SC опубликована под MIT и сама направляет пользователей -к поддерживаемой версии NeMo -([репозиторий авторов](https://github.com/tango4j/Auto-Tuning-Spectral-Clustering/tree/ae97f9be9c33b554205c7192c5137ce8f456c1b7)). -Файл NeMo с clustering дополнительно сохраняет BSD-уведомление scikit-learn. -NumPy имеет BSD-3-Clause -([лицензия 2.4.3](https://github.com/numpy/numpy/blob/v2.4.3/LICENSE.txt)) и уже -присутствует в [lock-файле проекта](../../uv.lock). Лицензионного запрета на -прототип нет; -прямое заимствование кода потребует сохранить его уведомления. Новые модели -для счетчика не нужны. - -## Ограничения вывода - -- NME-SC проверялась авторами на другой нарезке и x-vector. Строки sherpa - это - локальные говорящие перекрывающихся Pyannote-окон после исключения overlap; - переносимость нельзя считать доказанной. -- Правильное `N` не гарантирует качественную разметку говорящих. Для TitaNet в - режиме известного числа кластеров уже наблюдался малый кластер, поэтому нужны - прежние proxy-метрики и слуховая проверка. -- У sherpa есть отдельный путь для единственного окна, который обходится без - общей матрицы эмбеддингов. Прототип не должен ломать этот special case. -- Python binding `FastClustering` нормирует переданную матрицу на месте; - сравниваемым вариантам нужно выдавать отдельные C-contiguous копии. -- Цена NME на числе строк, которое реально создает sherpa, здесь не измерялась. - -## Точная граница будущего прототипа - -Production-код, CLI, модели по умолчанию и ADR не менять. Сделать отдельный -исследовательский стенд на закрепленном sherpa-onnx 1.13.5: - -1. Узко разделить внутренний `Process` на подготовку и финализацию либо добавить - исследовательский callback после `ComputeEmbeddings`. За один проход - сохранить в памяти матрицу `float32`, список валидных пар - `окно x локальный говорящий` и локальные frame labels; аудио и текст не - сериализовать. -2. На **одной и той же** матрице сравнить три счетчика: - текущий порог как контроль, простой eigengap с фиксированным прореживанием и - count-only NME. Ограничить `N` диапазоном `1..8`. -3. Минимальный NME реализовать по статье на NumPy: cosine affinity, - top-`p` binarization, симметризация, ненормированный Laplacian, - `numpy.linalg.eigvalsh`, выбор `p` и `N` по NME. Для первого прогона взять - детерминированное ограничение 512 строк и sparse search из 20 значений; - anchor embeddings, Torch, spectral embeddings и k-means не включать. -4. Для каждого оцененного `N` вызвать публичный `FastClustering` на копии той же - матрицы с `num_clusters=N`, затем выполнить существующую финализацию без - повторной сегментации или извлечения эмбеддингов. -5. На тех же трех контрольных фрагментах измерить: оцененное `N`, общее и - содержательное число кластеров, mapped speaker purity, слова без говорящего, - время счетчика/кластеризации и peak RSS. Прогон с CAMPPlus и TitaNet должен - завершиться слуховой проверкой. -6. Если простой eigengap и NME одинаково проходят проверку, выбрать более - дешевый eigengap. Если проходит только NME, отдельно решить, переносить ли - чистую NumPy-реализацию в production. Если оба ошибаются, быстрые embedding- - модели оставить только для явно заданного числа говорящих. - -Этот прототип отвечает ровно на два неизвестных: можно ли стабильно оценить -`N` по внутренним эмбеддингам CAMPPlus/TitaNet и укладывается ли count-only NME -в приемлемую CPU-цену. Он не выбирает новую модель по умолчанию, не заменяет -FastClustering и не расширяет продуктовый контракт.