Files
local-transcriber/docs/research/2026-08-30-diarization-speaker-count.md
T
Dmitry Dementiev e3c9040bfe docs(research): выбрана схема оценки числа говорящих
- Зачем:
  - быстрым моделям эмбеддингов нужен автоматический подсчет без повторного инференса.
- Что:
  - проверены возможности повторного использования эмбеддингов в публичном API sherpa-onnx.
  - сопоставлены порог, eigengap, NME-SC и двухшаговая кластеризация.
  - зафиксирована граница NumPy-прототипа без production-зависимости от NeMo.
- Проверка:
  - git diff --cached --check.
2026-08-30 13:42:42 +03:00

24 KiB
Raw Blame History

Оценка числа говорящих для быстрых эмбеддингов

Дата: 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 и закреплено в комментарии к FastClusteringConfig: при известном числе кластеров порог игнорируется. Python binding публично принимает C-contiguous матрицу float32 размера M x D и возвращает метку для каждой строки (исходник binding). Значит, передавать оцененное N в существующий кластеризатор не требует новой реализации распределения эмбеддингов по кластерам.

Порог FastClustering не принадлежит модели эмбеддингов. Это высота complete-linkage дерева в шкале cosine dissimilarity. Официальный пример CAMPPlus для speaker verification лишь вычисляет cosine score и не задает универсальной границы решения (infer_sv.py). Официальный diarization-рецепт 3D-Speaker отдельно задает pval=0.012 для spectral clustering и mer_cos=0.8 для последующего слияния центров (infer_diarization.py). Эти величины имеют другой смысл и не могут быть перенесены в sherpa как порог модели.

Публичных деталей недостаточно для повтора всего конвейера

Публичный SpeakerEmbeddingExtractor позволяет создать stream, подать в него аудио и получить один embedding (Python binding, C/C++ API). Это пригодно для самостоятельно выбранного фрагмента, но не выдает материал, который выбрал diarization-конвейер.

Внутри OfflineSpeakerDiarization после сегментации:

  1. из перекрывающихся окон строятся пары окно x локальный говорящий;
  2. речь с одновременными голосами исключается;
  3. для каждой пары собирается свой stream и вычисляется embedding;
  4. вся матрица передается в FastClustering;
  5. метки строк переводятся обратно в разметку говорящих.

Последовательность и временная матрица видны в OfflineSpeakerDiarizationPyannoteImpl::Process, а сборка строк - в GetChunkSpeakerSampleIndexes и ComputeEmbeddings. Это private-методы реализации.

Python-класс OfflineSpeakerDiarization экспортирует только sample_rate, set_config и process; process возвращает готовый результат, а не признаки (binding 1.13.5). Внутренний SetConfig заменяет только объект кластеризации, тогда как каждый Process заново вызывает segmentation и ComputeEmbeddings (реализация). Актуальная публичная C API также документирует только создание, настройку и получение итоговых сегментов (официальная страница).

Итог: публичный API позволяет кластеризовать имеющуюся матрицу повторно. Извлечь или переиграть промежуточную матрицу штатного diarization-конвейера через него нельзя.

Варианты оценки N

1. Отдельный порог для каждой embedding-модели

Это текущий FastClustering в автоматическом режиме. Он дешев, не добавляет зависимостей и на одном проходе сразу оценивает число кластеров и назначает метки. На порог влияют архитектура модели эмбеддингов, длительность и состав выбранных фрагментов, акустика и распределение голосов. Локальная калибровка уже не нашла общего рабочего порога для CAMPPlus, а TitaNet не прошел автоматическую сетку (исходное исследование, повторный CPU-тест).

Вводить еще один официальный порог модели оснований нет: upstream-рецепты публикуют параметры конкретного clustering pipeline, а не переносимую границу для complete-linkage sherpa. Вариант остается контрольным, но не решает тикет.

2. Spectral clustering с простым eigengap

Официальный CAMPPlus-конвейер 3D-Speaker строит cosine affinity, оставляет в каждой строке фиксированную долю ближайших соседей, симметризует матрицу, вычисляет ненормированный Laplacian и несколько наименьших собственных пар. Число говорящих - позиция максимального eigengap в заданных пределах, после чего собственные векторы кластеризуются k-means (SpectralCluster).

Для нашей двухшаговой схемы нужны только собственные значения и оценка N; k-means можно не выполнять. Это одна spectral decomposition и потому хороший минимальный контрольный вариант. Однако фиксированный pval остается новым порогом. Официальная реализация задает его для своего способа нарезки; пары окно x локальный говорящий sherpa устроены иначе. Вариант отделяет N от порога AHC, но сохраняет калибруемый параметр.

Если брать реализацию 3D-Speaker как зависимость, она импортирует NumPy, SciPy, scikit-learn, fastcluster, UMAP и HDBSCAN, причем последние два обязательны уже при импорте модуля (исходник, requirements). Для production это несоразмерно счетчику.

3. NME-SC

NME-SC создан именно для совместной оценки числа кластеров и параметра прореживания без dev-set. Для каждого числа соседей p алгоритм бинаризует cosine affinity, симметризует ее, строит ненормированный Laplacian и считает eigengap. Нормированный gap равен максимальному gap, деленному на наибольшее собственное значение; выбирается p, минимизирующий отношение p / gap, а N задает позиция максимального gap (статья авторов, раздел III). Авторы ограничивали поиск p диапазоном до четверти числа фрагментов и число говорящих - восемью; результаты статьи получены на x-vector и англоязычных корпусах, поэтому перенос на наши эмбеддинги еще предстоит проверить.

Поддерживаемая реализация NeMo принимает готовые эмбеддинги, строит cosine affinity и отдельно получает est_num_of_spk; известное число говорящих затем подменяет эту оценку перед spectral clustering (offline_clustering.py). Для ускорения NME она по умолчанию ограничивает матрицу анализа 512 строками, перебирает разреженную сетку из 30 значений p и предупреждает, что меньше 20 может ухудшить оценку (класс NMESC). Для коротких записей NeMo добавляет случайные anchor embeddings и повторяет оценку. Это отдельное расширение за пределами минимального ядра статьи (исходник).

Полная NME-SC после оценки N еще раз раскладывает Laplacian, строит spectral embeddings и запускает k-means. Для этого тикета такая замена итогового кластеризатора не нужна: локальные тесты уже показывают полезный путь FastClustering с известным числом говорящих. Нужна только функция embeddings -> N.

4. Оценить N, затем передать известное число кластеров

Это не отдельный счетчик. Это рекомендуемая граница ответственности:

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).

Вариант Новые 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 (профиль стоимости, CPU-тест); это не заменяет отдельного замера NME на целевой матрице.

sherpa-onnx, 3D-Speaker и NeMo Speech распространяют код под Apache-2.0 (sherpa-onnx, 3D-Speaker, NeMo Speech). Авторская реализация NME-SC опубликована под MIT и сама направляет пользователей к поддерживаемой версии NeMo (репозиторий авторов). Файл NeMo с clustering дополнительно сохраняет BSD-уведомление scikit-learn. NumPy имеет BSD-3-Clause (лицензия 2.4.3) и уже присутствует в 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 и не расширяет продуктовый контракт.