docs(research): уточнена граница прототипа оценки говорящих

- Зачем:
  - исследовательское решение должно однозначно задавать повторное использование эмбеддингов и проверку счетчиков.
- Что:
  - выбран внутренний интерфейс подготовки и финализации sherpa-onnx.
  - закреплены входы, вариант NME и критерии качества.
  - измерения CPU и памяти отделены от будущих гейтов карты.
- Проверка:
  - `uv run pytest -q` (334 passed, 2 skipped).
  - `git diff --check`.
  - 31 внешняя ссылка отвечает HTTP 200.
This commit is contained in:
Dmitry Dementiev committed 2026-08-30 21:50:51 +03:00
1 parent e3c9040bfe
commit 66fc264f2d
1 file changed
+256 -135
@@ -16,10 +16,11 @@
Нужна двухшаговая схема: один раз получить ровно те эмбеддинги, которые Нужна двухшаговая схема: один раз получить ровно те эмбеддинги, которые
подготовил конвейер sherpa-onnx, отдельно оценить по ним число голосовых подготовил конвейер sherpa-onnx, отдельно оценить по ним число голосовых
кластеров `N`, затем передать `N` штатному `FastClustering`. Для первого кластеров `N`, затем передать `N` штатному `FastClustering`. Для первого
прототипа рекомендован **только счетчик NME**, без полной spectral clustering: прототипа рекомендован **счетчик NME**, без полной спектральной кластеризации:
ядро алгоритма из статьи реализовать на уже доступном NumPy, а итоговые метки ядро алгоритма из статьи реализовать на NumPy, а итоговые метки по-прежнему
по-прежнему получить через `FastClustering(num_clusters=N)`. Простой eigengap с получить через `FastClustering(num_clusters=N)`.
фиксированным прореживанием нужен как дешевый контрольный вариант. Счетчик eigengap с фиксированным прореживанием нужен как дешевый контрольный
вариант. Пока это гипотеза для прототипа, а не выбор production-алгоритма.
Публичного промежуточного интерфейса для этого в sherpa-onnx 1.13.5 нет. Публичного промежуточного интерфейса для этого в sherpa-onnx 1.13.5 нет.
`SpeakerEmbeddingExtractor` и `FastClustering` публичны, но `SpeakerEmbeddingExtractor` и `FastClustering` публичны, но
@@ -34,109 +35,111 @@
### `FastClustering` уже умеет второй шаг ### `FastClustering` уже умеет второй шаг
В версии 1.13.5 алгоритм нормирует строки, строит все попарные cosine В версии 1.13.5 алгоритм нормирует строки, строит все попарные косинусные
dissimilarity, выполняет complete-linkage AHC и затем выбирает один из двух расстояния, выполняет AHC методом полной связи и затем выбирает один из двух
способов разрезать одно и то же дерево: способов разрезать одно и то же дерево:
- при `num_clusters > 0` - ровно на заданное число кластеров; - при `num_clusters > 0`: ровно на заданное число кластеров;
- иначе - по высоте `threshold`. - иначе: по высоте `threshold`.
Это видно непосредственно в Так работает
[`fast-clustering.cc`](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/csrc/fast-clustering.cc) [`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): [`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` и возвращает метку для принимает 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` в существующий кластеризатор не требует новой Значит, передавать оцененное `N` в существующий кластеризатор не требует новой
реализации распределения эмбеддингов по кластерам. реализации распределения эмбеддингов по кластерам.
Порог `FastClustering` не принадлежит модели эмбеддингов. Это высота Порог `FastClustering` не принадлежит модели эмбеддингов: он задает высоту
complete-linkage дерева в шкале cosine dissimilarity. Официальный пример дерева полной связи в шкале косинусного расстояния. Официальный пример CAMPPlus
CAMPPlus для speaker verification лишь вычисляет cosine score и не задает для проверки говорящего лишь вычисляет косинусное сходство и не задает
универсальной границы решения универсальной границы решения
([`infer_sv.py`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/bin/infer_sv.py)). ([`infer_sv.py`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/bin/infer_sv.py)).
Официальный diarization-рецепт 3D-Speaker отдельно задает `pval=0.012` для Официальный рецепт диаризации 3D-Speaker отдельно задает `pval=0.012` для
spectral clustering и `mer_cos=0.8` для последующего слияния центров спектральной кластеризации и `mer_cos=0.8` для последующего слияния центров
([`infer_diarization.py`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/bin/infer_diarization.py)). ([`infer_diarization.py`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/bin/infer_diarization.py)).
Эти величины имеют другой смысл и не могут быть перенесены в sherpa как Эти величины имеют другой смысл и не могут быть перенесены в sherpa как
`порог модели`. `порог модели`.
### Публичных деталей недостаточно для повтора всего конвейера ### Публичных деталей недостаточно для повтора всего конвейера
Публичный `SpeakerEmbeddingExtractor` позволяет создать stream, подать в него Публичный `SpeakerEmbeddingExtractor` позволяет создать поток, подать в него
аудио и получить один embedding аудио и получить один эмбеддинг
([Python binding](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/speaker-embedding-extractor.cc), ([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)). [C/C++ API](https://k2-fsa.github.io/sherpa/onnx/c-api/html/speaker_embedding.html)).
Это пригодно для самостоятельно выбранного фрагмента, но не выдает материал, Интерфейс пригоден для самостоятельно выбранного фрагмента, но не выдает
который выбрал diarization-конвейер. материал, который выбрал конвейер диаризации.
Внутри `OfflineSpeakerDiarization` после сегментации: Внутри `OfflineSpeakerDiarization` после сегментации:
1. из перекрывающихся окон строятся пары `окно x локальный говорящий`; 1. из перекрывающихся окон строятся пары `окно x локальный говорящий`;
2. речь с одновременными голосами исключается; 2. речь с одновременными голосами исключается;
3. для каждой пары собирается свой stream и вычисляется embedding; 3. для каждой пары собирается свой поток и вычисляется эмбеддинг;
4. вся матрица передается в `FastClustering`; 4. вся матрица передается в `FastClustering`;
5. метки строк переводятся обратно в разметку говорящих. 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), [`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). [`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`, Python-класс `OfflineSpeakerDiarization` экспортирует только `sample_rate`,
`set_config` и `process`; `process` возвращает готовый результат, а не признаки `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` заменяет только объект кластеризации, тогда как каждый Внутренний `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)). ([реализация](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)). ([официальная страница](https://k2-fsa.github.io/sherpa/onnx/c-api/html/speaker_diarization.html)).
Итог: публичный API позволяет **кластеризовать имеющуюся матрицу повторно**. Итог: публичный API позволяет **кластеризовать имеющуюся матрицу повторно**.
Извлечь или переиграть промежуточную матрицу штатного diarization-конвейера Извлечь или повторно обработать промежуточную матрицу штатного конвейера
через него нельзя. диаризации через него нельзя.
## Варианты оценки `N` ## Варианты оценки `N`
### 1. Отдельный порог для каждой embedding-модели ### 1. Отдельный порог для каждой модели эмбеддингов
Это текущий `FastClustering` в автоматическом режиме. Он дешев, не добавляет Автоматический режим `FastClustering` дешев, не добавляет зависимостей и за один
зависимостей и на одном проходе сразу оценивает число кластеров и назначает проход оценивает число кластеров и назначает метки. На порог влияют архитектура
метки. На порог влияют архитектура модели эмбеддингов, длительность и состав модели эмбеддингов, длительность и состав выбранных окон, акустика и
выбранных фрагментов, акустика и распределение голосов. Локальная калибровка распределение голосов. Локальная калибровка уже не нашла общего рабочего порога
уже не нашла общего рабочего порога для CAMPPlus, для CAMPPlus, а TitaNet не прошел автоматическую сетку
а TitaNet не прошел автоматическую сетку
([исходное исследование](2026-08-14-diarization-cpu-cost.md), ([исходное исследование](2026-08-14-diarization-cpu-cost.md),
[повторный CPU-тест](../benchmarks/2026-08-30-lightweight-diarization-cpu.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, оставляет в Официальный CAMPPlus-конвейер 3D-Speaker строит матрицу косинусного сходства.
каждой строке фиксированную долю ближайших соседей, симметризует матрицу, В каждой строке он оставляет фиксированную долю ближайших соседей,
вычисляет ненормированный Laplacian и несколько наименьших собственных пар. симметризует матрицу, затем вычисляет ненормированный лапласиан и несколько
Число говорящих - позиция максимального eigengap в заданных пределах, после наименьших собственных пар.
чего собственные векторы кластеризуются k-means Число говорящих задает позиция максимального разрыва собственных значений
(`eigengap`) в заданных пределах, после чего собственные векторы
кластеризуются k-means
([`SpectralCluster`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/process/cluster.py#L21-L106)). ([`SpectralCluster`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/process/cluster.py#L21-L106)).
Для нашей двухшаговой схемы нужны только собственные значения и оценка `N`; Для нашей двухшаговой схемы нужны только собственные значения и оценка `N`;
k-means можно не выполнять. Это одна spectral decomposition и потому хороший k-means можно не выполнять. Одного спектрального разложения достаточно для
минимальный контрольный вариант. Однако фиксированный `pval` остается новым минимального контрольного варианта, но фиксированный `pval` остается новым
порогом. порогом. Официальная реализация задает его для своего способа нарезки; пары
Официальная реализация задает его для своего способа нарезки; пары
`окно x локальный говорящий` sherpa устроены иначе. Вариант отделяет `N` от `окно x локальный говорящий` sherpa устроены иначе. Вариант отделяет `N` от
порога AHC, но сохраняет калибруемый параметр. порога AHC, но сохраняет калибруемый параметр.
Если брать реализацию 3D-Speaker как зависимость, она импортирует NumPy, SciPy, Если брать реализацию 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), ([исходник](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)). [requirements](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/requirements.txt)).
Для production это несоразмерно счетчику. Для production это несоразмерно счетчику.
@@ -144,49 +147,61 @@ scikit-learn, fastcluster, UMAP и HDBSCAN, причем последние дв
### 3. NME-SC ### 3. NME-SC
NME-SC создан именно для совместной оценки числа кластеров и параметра NME-SC создан именно для совместной оценки числа кластеров и параметра
прореживания без dev-set. Для каждого числа соседей `p` алгоритм бинаризует прореживания без отдельной выборки для настройки. Для каждого числа соседей
cosine affinity, симметризует ее, строит ненормированный Laplacian и считает `p` алгоритм бинаризует матрицу косинусного сходства, симметризует ее, строит
eigengap. Нормированный gap равен максимальному gap, деленному на наибольшее ненормированный лапласиан и считает разрывы собственных значений. Нормированный
собственное значение; выбирается `p`, минимизирующий отношение `p / gap`, а `N` разрыв равен максимальному разрыву, деленному на наибольшее собственное
задает позиция максимального gap значение; выбирается `p`, минимизирующий отношение доли соседей `p/M` к
нормированному разрыву, а `N` задает позиция максимального разрыва
([статья авторов, раздел III](https://arxiv.org/abs/2003.02405)). ([статья авторов, раздел III](https://arxiv.org/abs/2003.02405)).
Авторы ограничивали поиск `p` диапазоном до четверти числа фрагментов и число Авторы ограничивали поиск `p` диапазоном до четверти числа строк, а число
говорящих - восемью; результаты статьи получены на x-vector и англоязычных говорящих числом восемь. Результаты статьи получены на x-vector и англоязычных
корпусах, поэтому перенос на наши эмбеддинги еще предстоит проверить. корпусах, поэтому перенос на наши эмбеддинги еще предстоит проверить.
Поддерживаемая реализация NeMo принимает готовые эмбеддинги, строит cosine Поддерживаемая реализация NeMo разделяет оценку и назначение меток. `NMESC`
affinity и отдельно получает `est_num_of_spk`; известное число говорящих затем принимает готовую матрицу сходства и возвращает `est_num_of_spk` вместе с
подменяет эту оценку перед spectral clustering выбранным `p`; следующий слой отдельно выбирает оцененное или известное число
([`offline_clustering.py`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L1087-L1161)). говорящих перед спектральной кластеризацией
Для ускорения NME она по умолчанию ограничивает матрицу анализа 512 строками, ([`NMESC` и выбор числа кластеров](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L909-L1041),
перебирает разреженную сетку из 30 значений `p` и предупреждает, что меньше 20 [`SpeakerClustering.forward_unit_infer`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L1181-L1259)).
может ухудшить оценку Более высокий интерфейс NeMo получает многомасштабные эмбеддинги вместе с
([класс `NMESC`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L801-L958)). временной разметкой и сам строит матрицу сходства
Для коротких записей NeMo добавляет случайные anchor embeddings и повторяет ([`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)). ([исходник](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/nemo/collections/asr/parts/utils/offline_clustering.py#L536-L595)).
Полная NME-SC после оценки `N` еще раз раскладывает Laplacian, строит spectral Полная NME-SC после оценки `N` еще раз раскладывает лапласиан, строит
embeddings и запускает k-means. Для этого тикета такая замена итогового спектральные эмбеддинги и запускает k-means. Для этого тикета такая замена
кластеризатора не нужна: локальные тесты уже показывают полезный путь итогового кластеризатора не нужна: локальные тесты уже показывают полезный путь
`FastClustering` с известным числом говорящих. Нужна только функция `FastClustering` с известным числом говорящих. Нужна только функция
`embeddings -> N`. `embeddings -> N`.
### 4. Оценить `N`, затем передать известное число кластеров ## Рекомендуемая граница ответственности
Это не отдельный счетчик. Это рекомендуемая граница ответственности: Здесь речь не об отдельном счетчике, а о способе соединить оценку `N` со
штатным кластеризатором:
```text ```text
segmentation -> embeddings +-> count-only NME -> N segmentation -> embeddings +-> счетчик NME -> N
+-> FastClustering(features, num_clusters=N) +-> FastClustering(features, num_clusters=N)
-> штатная финализация sherpa -> штатная финализация sherpa
``` ```
Плюсы: Плюсы:
- порог complete-linkage больше не определяет число голосовых кластеров; - порог метода полной связи больше не определяет число голосовых кластеров;
- CAMPPlus и TitaNet используют один модельный проход; - CAMPPlus и TitaNet используют один модельный проход;
- итоговое назначение меток остается в уже принятом FastClustering; - итоговое назначение меток остается в уже принятом `FastClustering`;
- на одной матрице можно независимо сравнить несколько счетчиков. - на одной матрице можно независимо сравнить несколько счетчиков.
Минус один, но архитектурный: без нового промежуточного интерфейса текущий Минус один, но архитектурный: без нового промежуточного интерфейса текущий
@@ -198,84 +213,190 @@ segmentation -> embeddings +-> count-only NME -> N
Штатная установка NeMo требует среди прочего NumPy, scikit-learn и PyTorch Штатная установка NeMo требует среди прочего NumPy, scikit-learn и PyTorch
([`pyproject.toml`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/pyproject.toml)). ([`pyproject.toml`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/pyproject.toml)).
| Вариант | Новые runtime-зависимости | Дополнительная работа после эмбеддингов | Ограничение | | Вариант | Зависимости прототипа | Работа после эмбеддингов | Ограничение |
|---|---|---|---| |---|---|---|---|
| Порог `FastClustering` | нет | уже существующие попарные расстояния и AHC | порог калибруется для модели и материала | | Порог `FastClustering` | новых нет | существующие попарные расстояния и AHC | порог зависит от модели и материала |
| Простой eigengap | SciPy в официальной реализации; либо только уже доступный NumPy | affinity `M x M`, одно собственное разложение | фиксированный параметр прореживания | | Счетчик eigengap | NumPy; официальный модуль 3D-Speaker целиком не импортируется | матрица `M x M`, одно собственное разложение | фиксированное прореживание остается параметром |
| Полная NME-SC NeMo | `nemo-toolkit`, PyTorch, scikit-learn и другие зависимости NeMo | до нескольких десятков eigenvalue-разложений, затем еще spectral embedding и k-means | самый тяжелый и меняет итоговый кластеризатор | | Полная NME-SC NeMo | `nemo-toolkit`, PyTorch, scikit-learn и другие зависимости NeMo | несколько разложений, затем спектральные эмбеддинги и k-means | меняет итоговый кластеризатор и слишком тяжела для счетчика |
| Счетчик NME + FastClustering | только NumPy при чистой реализации ядра статьи | NME на ограниченной матрице, затем штатный AHC по известному `N` | нужен промежуточный интерфейс и локальная проверка переноса | | Счетчик NME + `FastClustering` | NumPy | несколько разложений, затем штатный AHC по известному `N` | нужен внутренний интерфейс sherpa и проверка переноса |
`FastClustering` уже хранит `M(M-1)/2` расстояний и строит AHC. Простой `FastClustering` уже хранит `M(M-1)/2` расстояний и строит AHC. Счетчик eigengap
eigengap добавляет еще одну плотную affinity-матрицу. NME повторяет eigenvalue- добавляет плотную матрицу сходства и одно собственное разложение. NME
анализ для набора `p`; ограничение размера и разреженный поиск контролируют повторяет анализ для набора `p`. Полная спектральная кластеризация также хранит
цену, но их влияние на качество нужно измерить. Полная spectral clustering собственные векторы и запускает k-means, поэтому для одной оценки `N` избыточна.
также хранит собственные векторы и запускает k-means, поэтому для одной оценки В проектных замерах основной расход пока приходится на извлечение эмбеддингов,
`N` избыточна. В проектных замерах основной расход по-прежнему приходится на а не на кластеризацию
извлечение эмбеддингов, а не на clustering
([профиль стоимости](2026-08-14-diarization-cpu-cost.md), ([профиль стоимости](2026-08-14-diarization-cpu-cost.md),
[CPU-тест](../benchmarks/2026-08-30-lightweight-diarization-cpu.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, 3D-Speaker и NeMo Speech распространяют код под Apache-2.0
([sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/LICENSE), ([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), [3D-Speaker](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/LICENSE),
[NeMo Speech](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/LICENSE)). [NeMo Speech](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/LICENSE)).
Авторская реализация NME-SC опубликована под MIT и сама направляет пользователей Авторская реализация NME-SC опубликована под MIT и направляет пользователей к
к поддерживаемой версии NeMo поддерживаемой версии NeMo
([репозиторий авторов](https://github.com/tango4j/Auto-Tuning-Spectral-Clustering/tree/ae97f9be9c33b554205c7192c5137ce8f456c1b7)). ([репозиторий авторов](https://github.com/tango4j/Auto-Tuning-Spectral-Clustering/tree/ae97f9be9c33b554205c7192c5137ce8f456c1b7)).
Файл NeMo с clustering дополнительно сохраняет BSD-уведомление scikit-learn. Файл кластеризации NeMo дополнительно сохраняет BSD-уведомление scikit-learn,
NumPy имеет BSD-3-Clause NumPy 2.4.3 использует BSD-3-Clause
([лицензия 2.4.3](https://github.com/numpy/numpy/blob/v2.4.3/LICENSE.txt)) и уже ([лицензия](https://github.com/numpy/numpy/blob/v2.4.3/LICENSE.txt)).
присутствует в [lock-файле проекта](../../uv.lock). Лицензионного запрета на Лицензионного запрета на прототип нет. Прямое заимствование кода потребует
прототип нет; сохранить его уведомления; реализация по формулам статьи должна явно назвать
прямое заимствование кода потребует сохранить его уведомления. Новые модели происхождение алгоритма.
для счетчика не нужны.
## Ограничения вывода ## Ограничения вывода
- NME-SC проверялась авторами на другой нарезке и x-vector. Строки sherpa - это - NME-SC проверялась авторами на другой нарезке и эмбеддингах x-vector. Строки
локальные говорящие перекрывающихся Pyannote-окон после исключения overlap; sherpa представляют локальных говорящих в перекрывающихся окнах Pyannote после
исключения пересечений речи;
переносимость нельзя считать доказанной. переносимость нельзя считать доказанной.
- Правильное `N` не гарантирует качественную разметку говорящих. Для TitaNet в - Правильное `N` не гарантирует качественную разметку говорящих. Для TitaNet в
режиме известного числа кластеров уже наблюдался малый кластер, поэтому нужны режиме известного числа кластеров уже наблюдался малый кластер, поэтому нужны
прежние proxy-метрики и слуховая проверка. прежние прокси-метрики и слуховая проверка.
- У sherpa есть отдельный путь для единственного окна, который обходится без - У sherpa есть отдельный путь для единственного окна, который обходится без
общей матрицы эмбеддингов. Прототип не должен ломать этот special case. общей матрицы эмбеддингов. Стенд должен сохранить этот частный случай, а также
- Python binding `FastClustering` нормирует переданную матрицу на месте; текущую обработку пустого результата и нечисловых эмбеддингов.
сравниваемым вариантам нужно выдавать отдельные C-contiguous копии. - Python-модуль `FastClustering` нормирует переданную матрицу на месте;
- Цена NME на числе строк, которое реально создает sherpa, здесь не измерялась. сравниваемым вариантам нужны отдельные C-contiguous копии.
- Оцененное `N` нужно проверять до вызова `FastClustering`:
`1 <= N <= min(8, M)`, где `M` обозначает число валидных строк. Некорректное
значение считается ошибкой ячейки, а не молча ограничивается.
- Прореживание строк может потерять голосовой кластер с малой долей речи.
Поэтому полный набор строк задает основной результат, а сокращенная матрица
годится только для отдельной проверки чувствительности.
- `numpy.linalg.eigvalsh` использует BLAS/LAPACK. Без закрепленного числа
потоков его процессорное и фактическое время нельзя сравнивать между
машинами.
## Точная граница будущего прототипа ## Точная граница будущего прототипа
Production-код, CLI, модели по умолчанию и ADR не менять. Сделать отдельный Production-код, CLI, модели по умолчанию и ADR не менять. Сделать отдельный
исследовательский стенд на закрепленном sherpa-onnx 1.13.5: исследовательский стенд на sherpa-onnx 1.13.5 и сохранить точный хеш коммита
экспериментальной сборки рядом с результатом.
1. Узко разделить внутренний `Process` на подготовку и финализацию либо добавить ### Внутренний интерфейс sherpa
исследовательский 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 разделить внутренний `Process` в одном месте:
`N` по внутренним эмбеддингам CAMPPlus/TitaNet и укладывается ли count-only NME после удаления нечисловых строк и синхронной фильтрации пар
в приемлемую CPU-цену. Он не выбирает новую модель по умолчанию, не заменяет `окно x локальный говорящий`, до `clustering_->Cluster`.
FastClustering и не расширяет продуктовый контракт.
- `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` и не расширяет продуктовый контракт.