Files
local-transcriber/docs/research/2026-08-30-diarization-speaker-count.md
T
Dmitry Dementiev 66fc264f2d docs(research): уточнена граница прототипа оценки говорящих
- Зачем:
  - исследовательское решение должно однозначно задавать повторное использование эмбеддингов и проверку счетчиков.
- Что:
  - выбран внутренний интерфейс подготовки и финализации sherpa-onnx.
  - закреплены входы, вариант NME и критерии качества.
  - измерения CPU и памяти отделены от будущих гейтов карты.
- Проверка:
  - `uv run pytest -q` (334 passed, 2 skipped).
  - `git diff --check`.
  - 31 внешняя ссылка отвечает HTTP 200.
2026-08-30 21:50:51 +03:00

35 KiB
Raw Blame History

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

Дата: 2026-08-30

Статус: исследовательская записка. Production-код и конфигурация не менялись.

Вопрос

Как отделить оценку числа говорящих от порога FastClustering, чтобы использовать быстрые CAMPPlus или TitaNet и не запускать сегментацию и извлечение эмбеддингов второй раз?

Краткий вывод

Нужна двухшаговая схема: один раз получить ровно те эмбеддинги, которые подготовил конвейер sherpa-onnx, отдельно оценить по ним число голосовых кластеров N, затем передать N штатному FastClustering. Для первого прототипа рекомендован счетчик NME, без полной спектральной кластеризации: ядро алгоритма из статьи реализовать на NumPy, а итоговые метки по-прежнему получить через FastClustering(num_clusters=N). Счетчик eigengap с фиксированным прореживанием нужен как дешевый контрольный вариант. Пока это гипотеза для прототипа, а не выбор production-алгоритма.

Публичного промежуточного интерфейса для этого в sherpa-onnx 1.13.5 нет. SpeakerEmbeddingExtractor и FastClustering публичны, но OfflineSpeakerDiarization.process() возвращает только готовые интервалы. Матрица эмбеддингов и соответствие строк локальным говорящим остаются внутри реализации. После set_config() повторный process() снова выполнит модельный инференс. Python-адаптер проекта сам по себе не может дать дешевую повторную кластеризацию; будущему прототипу нужен узкий исследовательский интерфейс на уровне sherpa-onnx.

Что подтверждено про sherpa-onnx

FastClustering уже умеет второй шаг

В версии 1.13.5 алгоритм нормирует строки, строит все попарные косинусные расстояния, выполняет AHC методом полной связи и затем выбирает один из двух способов разрезать одно и то же дерево:

  • при num_clusters > 0: ровно на заданное число кластеров;
  • иначе: по высоте threshold.

Так работает fast-clustering.cc и закреплено в комментарии к FastClusteringConfig: при известном числе кластеров порог игнорируется. Python-модуль публично принимает C-contiguous матрицу float32 размера M x D и возвращает метку для каждой строки (исходник Python-модуля). Значит, передавать оцененное N в существующий кластеризатор не требует новой реализации распределения эмбеддингов по кластерам.

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

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

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

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

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

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

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

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

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

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

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

Вводить еще один официальный порог модели оснований нет: исходные рецепты публикуют параметры конкретного конвейера кластеризации, а не переносимую границу для метода полной связи в sherpa. Вариант остается контрольным, но не решает тикет.

2. Счетчик eigengap

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

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

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

3. NME-SC

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

Поддерживаемая реализация NeMo разделяет оценку и назначение меток. NMESC принимает готовую матрицу сходства и возвращает est_num_of_spk вместе с выбранным p; следующий слой отдельно выбирает оцененное или известное число говорящих перед спектральной кластеризацией (NMESC и выбор числа кластеров, SpeakerClustering.forward_unit_infer). Более высокий интерфейс NeMo получает многомасштабные эмбеддинги вместе с временной разметкой и сам строит матрицу сходства (forward_infer).

Для ускорения NME NeMo задает целевой размер матрицы 512 строк, перебирает разреженную сетку из 30 значений p и предупреждает, что меньше 20 может ухудшить оценку (класс NMESC). Предел не жесткий: регулярный шаг вычисляется целочисленно, поэтому для некоторых размеров после прореживания остается больше 512 строк. Исходник также предупреждает о риске пропустить говорящего с малой долей речи (subsampleAffinityMat). Для коротких записей NeMo добавляет случайные якорные эмбеддинги и повторяет оценку. Такое расширение выходит за пределы минимального ядра статьи (исходник).

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

Рекомендуемая граница ответственности

Здесь речь не об отдельном счетчике, а о способе соединить оценку N со штатным кластеризатором:

segmentation -> embeddings +-> счетчик NME -> N
                           +-> FastClustering(features, num_clusters=N)
                                     -> штатная финализация sherpa

Плюсы:

  • порог метода полной связи больше не определяет число голосовых кластеров;
  • CAMPPlus и TitaNet используют один модельный проход;
  • итоговое назначение меток остается в уже принятом FastClustering;
  • на одной матрице можно независимо сравнить несколько счетчиков.

Минус один, но архитектурный: без нового промежуточного интерфейса текущий process() не позволяет вставить счетчик между вычислением матрицы и кластеризацией.

Зависимости, лицензии и цена

Штатная установка NeMo требует среди прочего NumPy, scikit-learn и PyTorch (pyproject.toml).

Вариант Зависимости прототипа Работа после эмбеддингов Ограничение
Порог 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 добавляет плотную матрицу сходства и одно собственное разложение. NME повторяет анализ для набора p. Полная спектральная кластеризация также хранит собственные векторы и запускает k-means, поэтому для одной оценки N избыточна. В проектных замерах основной расход пока приходится на извлечение эмбеддингов, а не на кластеризацию (профиль стоимости, CPU-тест); это не заменяет отдельного замера NME на фактическом числе строк.

NumPy сейчас есть только в lock-файле проекта, а не в прямых зависимостях local-transcriber. Для стенда версию 2.4.3 нужно закрепить явно. Если счетчик дойдет до поставляемого кода, NumPy потребуется объявить прямой зависимостью времени выполнения.

sherpa-onnx, 3D-Speaker и NeMo Speech распространяют код под Apache-2.0 (sherpa-onnx, 3D-Speaker, NeMo Speech). Авторская реализация NME-SC опубликована под MIT и направляет пользователей к поддерживаемой версии NeMo (репозиторий авторов). Файл кластеризации NeMo дополнительно сохраняет BSD-уведомление scikit-learn, NumPy 2.4.3 использует BSD-3-Clause (лицензия). Лицензионного запрета на прототип нет. Прямое заимствование кода потребует сохранить его уведомления; реализация по формулам статьи должна явно назвать происхождение алгоритма.

Ограничения вывода

  • NME-SC проверялась авторами на другой нарезке и эмбеддингах x-vector. Строки sherpa представляют локальных говорящих в перекрывающихся окнах Pyannote после исключения пересечений речи; переносимость нельзя считать доказанной.
  • Правильное N не гарантирует качественную разметку говорящих. Для TitaNet в режиме известного числа кластеров уже наблюдался малый кластер, поэтому нужны прежние прокси-метрики и слуховая проверка.
  • У 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

В экспериментальной ветке 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 и исследовании TitaNet. 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 из кода авторов:

Параметр Значение
Вход все 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. Сокращенная матрица не заменяет полный результат даже при совпадении.

Проверка и граница решения

Для каждой модели один раз подготовить матрицу на тех же трех пятиминутных контрольных записях и на ее независимых копиях выполнить все счетчики. Критерии качества:

  • оцененное число голосовых кластеров точно равно 3 / 2 / 2 на трех контрольных записях для каждой модели отдельно; содержательные кластеры не компенсируют ошибку счета;
  • метки строк и финальный результат для оцененного N совпадают с отдельным вызовом известного N на той же матрице;
  • повторный счет на той же матрице дает те же p, N и метки;
  • сохранены прежняя метрика mapped speaker purity, число слов без говорящего и диагностика малых кластеров; прогон CAMPPlus и TitaNet заканчивается слуховой проверкой.

Для каждого счетчика отдельно записать фактическое и процессорное время, peak RSS, форму матрицы, конфигурацию NumPy/BLAS и эффективное число потоков. Процесс запускать с одним потоком BLAS/LAPACK; способ ограничения и вывод numpy.show_config() входят в результат.

Числовой гейт времени и памяти эта записка не задает: его определит Зафиксировать аппаратную матрицу и бюджет памяти, а общую матрицу соберет Собрать минимальную матрицу следующего CPU-эксперимента. До этих решений время и память служат измерениями, но не основанием объявить счетчик принятым.

Если счетчики eigengap и NME проходят критерии качества, в следующую матрицу идет более дешевый. Если проходит только NME, отдельно решается перенос чистой NumPy-реализации в production. Если оба ошибаются, CAMPPlus и TitaNet остаются кандидатами только для явно заданного числа говорящих.

Прототип проверяет переносимость оценки N на внутренние строки sherpa и цену счетчика. Он не выбирает новую модель по умолчанию, не заменяет FastClustering и не расширяет продуктовый контракт.