From e3c9040bfe9a6132d04b3663841b5212c1c25f4d Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 30 Aug 2026 13:42:42 +0300 Subject: [PATCH 1/2] =?UTF-8?q?docs(research):=20=D0=B2=D1=8B=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0=20=D1=81=D1=85=D0=B5=D0=BC=D0=B0=20=D0=BE?= =?UTF-8?q?=D1=86=D0=B5=D0=BD=D0=BA=D0=B8=20=D1=87=D0=B8=D1=81=D0=BB=D0=B0?= =?UTF-8?q?=20=D0=B3=D0=BE=D0=B2=D0=BE=D1=80=D1=8F=D1=89=D0=B8=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - быстрым моделям эмбеддингов нужен автоматический подсчет без повторного инференса. - Что: - проверены возможности повторного использования эмбеддингов в публичном API sherpa-onnx. - сопоставлены порог, eigengap, NME-SC и двухшаговая кластеризация. - зафиксирована граница NumPy-прототипа без production-зависимости от NeMo. - Проверка: - git diff --cached --check. --- .../2026-08-30-diarization-speaker-count.md | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create 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 new file mode 100644 index 0000000..4fe3bb0 --- /dev/null +++ b/docs/research/2026-08-30-diarization-speaker-count.md @@ -0,0 +1,281 @@ +# Оценка числа говорящих для быстрых эмбеддингов + +**Дата:** 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 и не расширяет продуктовый контракт. From 66fc264f2d012cf637e5f1f8f75d7912e55030b0 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 30 Aug 2026 21:50:51 +0300 Subject: [PATCH 2/2] =?UTF-8?q?docs(research):=20=D1=83=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B0=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D1=82=D0=B8=D0=BF=D0=B0?= =?UTF-8?q?=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=B8=20=D0=B3=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D1=80=D1=8F=D1=89=D0=B8=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - исследовательское решение должно однозначно задавать повторное использование эмбеддингов и проверку счетчиков. - Что: - выбран внутренний интерфейс подготовки и финализации sherpa-onnx. - закреплены входы, вариант NME и критерии качества. - измерения CPU и памяти отделены от будущих гейтов карты. - Проверка: - `uv run pytest -q` (334 passed, 2 skipped). - `git diff --check`. - 31 внешняя ссылка отвечает HTTP 200. --- .../2026-08-30-diarization-speaker-count.md | 391 ++++++++++++------ 1 file changed, 256 insertions(+), 135 deletions(-) diff --git a/docs/research/2026-08-30-diarization-speaker-count.md b/docs/research/2026-08-30-diarization-speaker-count.md index 4fe3bb0..60fab09 100644 --- a/docs/research/2026-08-30-diarization-speaker-count.md +++ b/docs/research/2026-08-30-diarization-speaker-count.md @@ -16,10 +16,11 @@ Нужна двухшаговая схема: один раз получить ровно те эмбеддинги, которые подготовил конвейер sherpa-onnx, отдельно оценить по ним число голосовых кластеров `N`, затем передать `N` штатному `FastClustering`. Для первого -прототипа рекомендован **только счетчик NME**, без полной spectral clustering: -ядро алгоритма из статьи реализовать на уже доступном NumPy, а итоговые метки -по-прежнему получить через `FastClustering(num_clusters=N)`. Простой eigengap с -фиксированным прореживанием нужен как дешевый контрольный вариант. +прототипа рекомендован **счетчик NME**, без полной спектральной кластеризации: +ядро алгоритма из статьи реализовать на NumPy, а итоговые метки по-прежнему +получить через `FastClustering(num_clusters=N)`. +Счетчик eigengap с фиксированным прореживанием нужен как дешевый контрольный +вариант. Пока это гипотеза для прототипа, а не выбор production-алгоритма. Публичного промежуточного интерфейса для этого в sherpa-onnx 1.13.5 нет. `SpeakerEmbeddingExtractor` и `FastClustering` публичны, но @@ -34,109 +35,111 @@ ### `FastClustering` уже умеет второй шаг -В версии 1.13.5 алгоритм нормирует строки, строит все попарные cosine -dissimilarity, выполняет complete-linkage AHC и затем выбирает один из двух +В версии 1.13.5 алгоритм нормирует строки, строит все попарные косинусные +расстояния, выполняет AHC методом полной связи и затем выбирает один из двух способов разрезать одно и то же дерево: -- при `num_clusters > 0` - ровно на заданное число кластеров; -- иначе - по высоте `threshold`. +- при `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 публично +при известном числе кластеров порог игнорируется. Python-модуль публично принимает 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)). +([исходник Python-модуля](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 и не задает +Порог `FastClustering` не принадлежит модели эмбеддингов: он задает высоту +дерева полной связи в шкале косинусного расстояния. Официальный пример CAMPPlus +для проверки говорящего лишь вычисляет косинусное сходство и не задает универсальной границы решения ([`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` для последующего слияния центров +Официальный рецепт диаризации 3D-Speaker отдельно задает `pval=0.012` для +спектральной кластеризации и `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), +Публичный `SpeakerEmbeddingExtractor` позволяет создать поток, подать в него +аудио и получить один эмбеддинг +([Python-модуль](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; +3. для каждой пары собирается свой поток и вычисляется эмбеддинг; 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)). +([Python-модуль 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` +`Process` заново вызывает сегментацию и `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 также документирует только создание, настройку и -получение итоговых сегментов +Публичная C API также документирует только создание, настройку и получение +итоговых сегментов ([официальная страница](https://k2-fsa.github.io/sherpa/onnx/c-api/html/speaker_diarization.html)). Итог: публичный API позволяет **кластеризовать имеющуюся матрицу повторно**. -Извлечь или переиграть промежуточную матрицу штатного diarization-конвейера -через него нельзя. +Извлечь или повторно обработать промежуточную матрицу штатного конвейера +диаризации через него нельзя. ## Варианты оценки `N` -### 1. Отдельный порог для каждой embedding-модели +### 1. Отдельный порог для каждой модели эмбеддингов -Это текущий `FastClustering` в автоматическом режиме. Он дешев, не добавляет -зависимостей и на одном проходе сразу оценивает число кластеров и назначает -метки. На порог влияют архитектура модели эмбеддингов, длительность и состав -выбранных фрагментов, акустика и распределение голосов. Локальная калибровка -уже не нашла общего рабочего порога для CAMPPlus, -а TitaNet не прошел автоматическую сетку +Автоматический режим `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. Вариант остается контрольным, но не решает тикет. +Вводить еще один `официальный порог модели` оснований нет: исходные рецепты +публикуют параметры конкретного конвейера кластеризации, а не переносимую +границу для метода полной связи в sherpa. Вариант остается контрольным, но не +решает тикет. -### 2. Spectral clustering с простым eigengap +### 2. Счетчик eigengap -Официальный CAMPPlus-конвейер 3D-Speaker строит cosine affinity, оставляет в -каждой строке фиксированную долю ближайших соседей, симметризует матрицу, -вычисляет ненормированный Laplacian и несколько наименьших собственных пар. -Число говорящих - позиция максимального eigengap в заданных пределах, после -чего собственные векторы кластеризуются k-means +Официальный CAMPPlus-конвейер 3D-Speaker строит матрицу косинусного сходства. +В каждой строке он оставляет фиксированную долю ближайших соседей, +симметризует матрицу, затем вычисляет ненормированный лапласиан и несколько +наименьших собственных пар. +Число говорящих задает позиция максимального разрыва собственных значений +(`eigengap`) в заданных пределах, после чего собственные векторы +кластеризуются k-means ([`SpectralCluster`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/process/cluster.py#L21-L106)). Для нашей двухшаговой схемы нужны только собственные значения и оценка `N`; -k-means можно не выполнять. Это одна spectral decomposition и потому хороший -минимальный контрольный вариант. Однако фиксированный `pval` остается новым -порогом. -Официальная реализация задает его для своего способа нарезки; пары +k-means можно не выполнять. Одного спектрального разложения достаточно для +минимального контрольного варианта, но фиксированный `pval` остается новым +порогом. Официальная реализация задает его для своего способа нарезки; пары `окно x локальный говорящий` sherpa устроены иначе. Вариант отделяет `N` от порога AHC, но сохраняет калибруемый параметр. Если брать реализацию 3D-Speaker как зависимость, она импортирует NumPy, SciPy, -scikit-learn, fastcluster, UMAP и HDBSCAN, причем последние два обязательны уже -при импорте модуля +scikit-learn, fastcluster, UMAP и HDBSCAN, причем отсутствие UMAP или HDBSCAN +роняет уже импорт модуля. Корневой `requirements.txt` при этом не перечисляет +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 это несоразмерно счетчику. @@ -144,49 +147,61 @@ scikit-learn, fastcluster, UMAP и HDBSCAN, причем последние дв ### 3. NME-SC NME-SC создан именно для совместной оценки числа кластеров и параметра -прореживания без dev-set. Для каждого числа соседей `p` алгоритм бинаризует -cosine affinity, симметризует ее, строит ненормированный Laplacian и считает -eigengap. Нормированный gap равен максимальному gap, деленному на наибольшее -собственное значение; выбирается `p`, минимизирующий отношение `p / gap`, а `N` -задает позиция максимального gap +прореживания без отдельной выборки для настройки. Для каждого числа соседей +`p` алгоритм бинаризует матрицу косинусного сходства, симметризует ее, строит +ненормированный лапласиан и считает разрывы собственных значений. Нормированный +разрыв равен максимальному разрыву, деленному на наибольшее собственное +значение; выбирается `p`, минимизирующий отношение доли соседей `p/M` к +нормированному разрыву, а `N` задает позиция максимального разрыва ([статья авторов, раздел III](https://arxiv.org/abs/2003.02405)). -Авторы ограничивали поиск `p` диапазоном до четверти числа фрагментов и число -говорящих - восемью; результаты статьи получены на x-vector и англоязычных +Авторы ограничивали поиск `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 и повторяет -оценку. Это отдельное расширение за пределами минимального ядра статьи +Поддерживаемая реализация NeMo разделяет оценку и назначение меток. `NMESC` +принимает готовую матрицу сходства и возвращает `est_num_of_spk` вместе с +выбранным `p`; следующий слой отдельно выбирает оцененное или известное число +говорящих перед спектральной кластеризацией +([`NMESC` и выбор числа кластеров](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L909-L1041), +[`SpeakerClustering.forward_unit_infer`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L1181-L1259)). +Более высокий интерфейс NeMo получает многомасштабные эмбеддинги вместе с +временной разметкой и сам строит матрицу сходства +([`forward_infer`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L1301-L1403)). + +Для ускорения NME NeMo задает целевой размер матрицы 512 строк, перебирает +разреженную сетку из 30 значений `p` и предупреждает, что меньше 20 может +ухудшить оценку +([класс `NMESC`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L872-L977)). +Предел не жесткий: регулярный шаг вычисляется целочисленно, поэтому для +некоторых размеров после прореживания остается больше 512 строк. Исходник также +предупреждает о риске пропустить говорящего с малой долей речи +([`subsampleAffinityMat`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L1043-L1066)). +Для коротких записей NeMo добавляет случайные якорные эмбеддинги и повторяет +оценку. Такое расширение выходит за пределы минимального ядра статьи ([исходник](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. Для этого тикета такая замена итогового -кластеризатора не нужна: локальные тесты уже показывают полезный путь +Полная NME-SC после оценки `N` еще раз раскладывает лапласиан, строит +спектральные эмбеддинги и запускает k-means. Для этого тикета такая замена +итогового кластеризатора не нужна: локальные тесты уже показывают полезный путь `FastClustering` с известным числом говорящих. Нужна только функция `embeddings -> N`. -### 4. Оценить `N`, затем передать известное число кластеров +## Рекомендуемая граница ответственности -Это не отдельный счетчик. Это рекомендуемая граница ответственности: +Здесь речь не об отдельном счетчике, а о способе соединить оценку `N` со +штатным кластеризатором: ```text -segmentation -> embeddings +-> count-only NME -> N +segmentation -> embeddings +-> счетчик NME -> N +-> FastClustering(features, num_clusters=N) -> штатная финализация sherpa ``` Плюсы: -- порог complete-linkage больше не определяет число голосовых кластеров; +- порог метода полной связи больше не определяет число голосовых кластеров; - CAMPPlus и TitaNet используют один модельный проход; -- итоговое назначение меток остается в уже принятом FastClustering; +- итоговое назначение меток остается в уже принятом `FastClustering`; - на одной матрице можно независимо сравнить несколько счетчиков. Минус один, но архитектурный: без нового промежуточного интерфейса текущий @@ -198,84 +213,190 @@ segmentation -> embeddings +-> count-only NME -> N Штатная установка 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` | новых нет | существующие попарные расстояния и AHC | порог зависит от модели и материала | +| Счетчик eigengap | NumPy; официальный модуль 3D-Speaker целиком не импортируется | матрица `M x M`, одно собственное разложение | фиксированное прореживание остается параметром | +| Полная NME-SC NeMo | `nemo-toolkit`, PyTorch, scikit-learn и другие зависимости NeMo | несколько разложений, затем спектральные эмбеддинги и k-means | меняет итоговый кластеризатор и слишком тяжела для счетчика | +| Счетчик NME + `FastClustering` | NumPy | несколько разложений, затем штатный AHC по известному `N` | нужен внутренний интерфейс sherpa и проверка переноса | -`FastClustering` уже хранит `M(M-1)/2` расстояний и строит AHC. Простой -eigengap добавляет еще одну плотную affinity-матрицу. NME повторяет eigenvalue- -анализ для набора `p`; ограничение размера и разреженный поиск контролируют -цену, но их влияние на качество нужно измерить. Полная spectral clustering -также хранит собственные векторы и запускает k-means, поэтому для одной оценки -`N` избыточна. В проектных замерах основной расход по-прежнему приходится на -извлечение эмбеддингов, а не на clustering +`FastClustering` уже хранит `M(M-1)/2` расстояний и строит AHC. Счетчик eigengap +добавляет плотную матрицу сходства и одно собственное разложение. NME +повторяет анализ для набора `p`. Полная спектральная кластеризация также хранит +собственные векторы и запускает k-means, поэтому для одной оценки `N` избыточна. +В проектных замерах основной расход пока приходится на извлечение эмбеддингов, +а не на кластеризацию ([профиль стоимости](2026-08-14-diarization-cpu-cost.md), [CPU-тест](../benchmarks/2026-08-30-lightweight-diarization-cpu.md)); это не -заменяет отдельного замера NME на целевой матрице. +заменяет отдельного замера NME на фактическом числе строк. + +NumPy сейчас есть только в [lock-файле проекта](../../uv.lock), а не в прямых +зависимостях `local-transcriber`. Для стенда версию 2.4.3 нужно закрепить явно. +Если счетчик дойдет до поставляемого кода, NumPy потребуется объявить прямой +зависимостью времени выполнения. 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 +Авторская реализация 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). Лицензионного запрета на -прототип нет; -прямое заимствование кода потребует сохранить его уведомления. Новые модели -для счетчика не нужны. +Файл кластеризации NeMo дополнительно сохраняет BSD-уведомление scikit-learn, +NumPy 2.4.3 использует BSD-3-Clause +([лицензия](https://github.com/numpy/numpy/blob/v2.4.3/LICENSE.txt)). +Лицензионного запрета на прототип нет. Прямое заимствование кода потребует +сохранить его уведомления; реализация по формулам статьи должна явно назвать +происхождение алгоритма. ## Ограничения вывода -- NME-SC проверялась авторами на другой нарезке и x-vector. Строки sherpa - это - локальные говорящие перекрывающихся Pyannote-окон после исключения overlap; +- NME-SC проверялась авторами на другой нарезке и эмбеддингах x-vector. Строки + sherpa представляют локальных говорящих в перекрывающихся окнах Pyannote после + исключения пересечений речи; переносимость нельзя считать доказанной. - Правильное `N` не гарантирует качественную разметку говорящих. Для TitaNet в режиме известного числа кластеров уже наблюдался малый кластер, поэтому нужны - прежние proxy-метрики и слуховая проверка. + прежние прокси-метрики и слуховая проверка. - У sherpa есть отдельный путь для единственного окна, который обходится без - общей матрицы эмбеддингов. Прототип не должен ломать этот special case. -- Python binding `FastClustering` нормирует переданную матрицу на месте; - сравниваемым вариантам нужно выдавать отдельные C-contiguous копии. -- Цена NME на числе строк, которое реально создает sherpa, здесь не измерялась. + общей матрицы эмбеддингов. Стенд должен сохранить этот частный случай, а также + текущую обработку пустого результата и нечисловых эмбеддингов. +- Python-модуль `FastClustering` нормирует переданную матрицу на месте; + сравниваемым вариантам нужны отдельные C-contiguous копии. +- Оцененное `N` нужно проверять до вызова `FastClustering`: + `1 <= N <= min(8, M)`, где `M` обозначает число валидных строк. Некорректное + значение считается ошибкой ячейки, а не молча ограничивается. +- Прореживание строк может потерять голосовой кластер с малой долей речи. + Поэтому полный набор строк задает основной результат, а сокращенная матрица + годится только для отдельной проверки чувствительности. +- `numpy.linalg.eigvalsh` использует BLAS/LAPACK. Без закрепленного числа + потоков его процессорное и фактическое время нельзя сравнивать между + машинами. ## Точная граница будущего прототипа Production-код, CLI, модели по умолчанию и ADR не менять. Сделать отдельный -исследовательский стенд на закрепленном sherpa-onnx 1.13.5: +исследовательский стенд на 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- - модели оставить только для явно заданного числа говорящих. +### Внутренний интерфейс sherpa -Этот прототип отвечает ровно на два неизвестных: можно ли стабильно оценить -`N` по внутренним эмбеддингам CAMPPlus/TitaNet и укладывается ли count-only NME -в приемлемую CPU-цену. Он не выбирает новую модель по умолчанию, не заменяет -FastClustering и не расширяет продуктовый контракт. +В экспериментальной ветке sherpa разделить внутренний `Process` в одном месте: +после удаления нечисловых строк и синхронной фильтрации пар +`окно x локальный говорящий`, до `clustering_->Cluster`. + +- `Prepare(audio)` либо возвращает готовый результат существующего частного + пути, либо владеющий объект `PreparedDiarization`. Объект хранит матрицу + `float32`, выровненные пары, локальные покадровые метки, число говорящих по + кадрам и длину аудио, необходимые текущей финализации. +- Экспериментальный Python-модуль отдает C-contiguous копию матрицы и + непрозрачный дескриптор подготовленного состояния. Дескриптор живет только в + памяти процесса. +- `Finalize(handle, row_labels)` проверяет число меток и выполняет существующие + `ConvertChunkSpeakerToCluster`, `ReLabel`, `FinalizeLabels` и `ComputeResult`. + Так все счетчики используют одну подготовку и одну штатную финализацию. +- Пустая речь, одно окно, отсутствие валидных эмбеддингов и одна валидная строка + сохраняют нынешнее поведение. Аудио, текст, матрица и дескриптор не + сериализуются. + +### Закрепленные входы и счетчики + +Использовать только модель Pyannote Segmentation 3.0 FP32 `model.onnx` +(`220ad67ca923bef2fa91f2390c786097bf305bceb5e261d4af67b38e938e1079`) и две +модели эмбеддингов из предыдущих измерений: + +- CAMPPlus zh/en + (`aa3cfc16963a10586a9393f5035d6d6b57e98d358b347f80c2a30bf4f00ceba2`); +- TitaNet small + (`ad4a1802485d8b34c722d2a9d04249662f2ece5d28a7a039063ca22f515a789e`). + +Полные SHA-256 и происхождение уже записаны в +[калибровке CAMPPlus](../benchmarks/2026-08-14-diarization-calibration.md#модели) +и [исследовании TitaNet](2026-08-29-lightweight-diarization-models.md#проверенные-sha-256). +INT8-сегментация в эту матрицу не входит: она меняет локальные покадровые метки +и уже отклонена по результатам отдельного CPU-теста. + +На каждой исходной матрице сравнить: + +1. диагностический контроль `FastClustering`: для CAMPPlus пороги `1,05` и + `1,10`; для TitaNet использовать наименее плохую, но отклоненную точку + `1,10`; +2. счетчик eigengap по схеме 3D-Speaker с `pval=0.012`, `min_pnum=6`, без + спектральных эмбеддингов и k-means; +3. счетчик NME по контракту ниже. + +Контрольные пороги не становятся допустимыми настройками. Они только +воспроизводят уже наблюдавшийся переход между лишними кластерами и склейкой. + +### Контракт счетчика NME + +Контракт следует формулам статьи и варианту Sparse-Search-20 из +[кода авторов](https://github.com/tango4j/Auto-Tuning-Spectral-Clustering/blob/ae97f9be9c33b554205c7192c5137ce8f456c1b7/spectral_opt.py#L463-L495): + +| Параметр | Значение | +|---|---| +| Вход | все `M` валидных строк; строки конечны и имеют ненулевую норму | +| Матрица сходства | нормировать строки на копии, затем `A = E @ E.T` | +| Диапазон `p` | `1..floor(M/4)`; при `M < 6` ячейка счетчика помечается пропуском, а диагностический `FastClustering` выполняется независимо | +| Разреженный поиск | все значения, если верхняя граница не больше 20; иначе 20 целых точек `linspace` с обоими концами и удалением дублей | +| Бинаризация | сохранить top-`p` в каждой строке, включая диагональ; равные значения разрешать по меньшему индексу столбца | +| Граф | `(A_p + A_p.T) / 2`, затем обнулить диагональ | +| Лапласиан | ненормированный `L = D - A`, `D_ii = sum_j(abs(A_ij))` | +| Собственные значения | `numpy.linalg.eigvalsh`, по возрастанию; `epsilon = 1e-10` | +| Оценка `N` | `N = 1 + argmax(gaps[:min(8, M - 1)])`; при равенстве брать наименьший индекс | +| Выбор `p` | `argmin` по `(p/M) / (max_gap / (lambda_max + epsilon) + epsilon)`; при равенстве брать наименьшее `p` | +| Связность | если выбранный граф несвязен, взять первое большее `p` из той же сетки, которое делает его связным; отсутствие такого `p` считать ошибкой ячейки | + +После коррекции связности использовать `N`, рассчитанное для итогового `p`. +Статья и код авторов не задают стабильное разрешение равных значений или +поведение при слишком малой матрице; таблица фиксирует эти два локальных +правила воспроизводимости. Основной расчет идет по всем строкам, как в +авторской реализации. + +До прогона на рабочих записях закрепить две синтетические фикстуры с +блочными матрицами сходства и ожидаемыми `N=2` и `N=3`. Они проверяют +сортировку и разрешение равных значений в `argmax` и `argmin`. +Якорные эмбеддинги, Torch, спектральные эмбеддинги и k-means в этот счетчик не +входят. + +Основной результат считать на всех строках. Если `M > 512`, дополнительно +повторить NME с регулярным прореживанием NeMo, записать `M` до и после и +сравнить `N`. Сокращенная матрица не заменяет полный результат даже при +совпадении. + +### Проверка и граница решения + +Для каждой модели один раз подготовить матрицу на тех же трех пятиминутных +[контрольных записях](../benchmarks/2026-08-30-lightweight-diarization-cpu.md) +и на ее независимых копиях выполнить все счетчики. Критерии качества: + +- оцененное число голосовых кластеров точно равно `3 / 2 / 2` на трех + контрольных записях для каждой модели отдельно; содержательные кластеры не + компенсируют ошибку счета; +- метки строк и финальный результат для оцененного `N` совпадают с отдельным + вызовом известного `N` на той же матрице; +- повторный счет на той же матрице дает те же `p`, `N` и метки; +- сохранены прежняя метрика `mapped speaker purity`, число слов без говорящего и + диагностика малых кластеров; прогон CAMPPlus и TitaNet заканчивается слуховой + проверкой. + +Для каждого счетчика отдельно записать фактическое и процессорное время, +`peak RSS`, форму матрицы, конфигурацию NumPy/BLAS и эффективное число потоков. +Процесс запускать с одним потоком BLAS/LAPACK; способ ограничения и вывод +`numpy.show_config()` входят в результат. + +Числовой гейт времени и памяти эта записка не задает: его определит +[Зафиксировать аппаратную матрицу и бюджет памяти](https://git.dementev.space/ddmitry/local-transcriber/issues/35), +а общую матрицу соберет +[Собрать минимальную матрицу следующего CPU-эксперимента](https://git.dementev.space/ddmitry/local-transcriber/issues/34). +До этих решений время и память служат измерениями, но не основанием объявить +счетчик принятым. + +Если счетчики eigengap и NME проходят критерии качества, в следующую матрицу +идет более дешевый. Если проходит только NME, отдельно решается перенос чистой +NumPy-реализации в production. Если оба ошибаются, CAMPPlus и TitaNet остаются +кандидатами только для явно заданного числа говорящих. + +Прототип проверяет переносимость оценки `N` на внутренние строки sherpa и цену +счетчика. Он не выбирает новую модель по умолчанию, не заменяет +`FastClustering` и не расширяет продуктовый контракт.