Files
local-transcriber/docs/research/2026-08-30-diarization-speaker-count.md
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

403 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Оценка числа говорящих для быстрых эмбеддингов
**Дата:** 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`](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-модуль публично
принимает C-contiguous матрицу `float32` размера `M x D` и возвращает метку для
каждой строки
([исходник Python-модуля](https://github.com/k2-fsa/sherpa-onnx/blob/v1.13.5/sherpa-onnx/python/csrc/fast-clustering.cc)).
Значит, передавать оцененное `N` в существующий кластеризатор не требует новой
реализации распределения эмбеддингов по кластерам.
Порог `FastClustering` не принадлежит модели эмбеддингов: он задает высоту
дерева полной связи в шкале косинусного расстояния. Официальный пример CAMPPlus
для проверки говорящего лишь вычисляет косинусное сходство и не задает
универсальной границы решения
([`infer_sv.py`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/bin/infer_sv.py)).
Официальный рецепт диаризации 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` позволяет создать поток, подать в него
аудио и получить один эмбеддинг
([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)).
Интерфейс пригоден для самостоятельно выбранного фрагмента, но не выдает
материал, который выбрал конвейер диаризации.
Внутри `OfflineSpeakerDiarization` после сегментации:
1. из перекрывающихся окон строятся пары `окно x локальный говорящий`;
2. речь с одновременными голосами исключается;
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).
Оба метода закрыты внутри реализации.
Python-класс `OfflineSpeakerDiarization` экспортирует только `sample_rate`,
`set_config` и `process`; `process` возвращает готовый результат, а не признаки
([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` заново вызывает сегментацию и `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 позволяет **кластеризовать имеющуюся матрицу повторно**.
Извлечь или повторно обработать промежуточную матрицу штатного конвейера
диаризации через него нельзя.
## Варианты оценки `N`
### 1. Отдельный порог для каждой модели эмбеддингов
Автоматический режим `FastClustering` дешев, не добавляет зависимостей и за один
проход оценивает число кластеров и назначает метки. На порог влияют архитектура
модели эмбеддингов, длительность и состав выбранных окон, акустика и
распределение голосов. Локальная калибровка уже не нашла общего рабочего порога
для CAMPPlus, а TitaNet не прошел автоматическую сетку
([исходное исследование](2026-08-14-diarization-cpu-cost.md),
[повторный CPU-тест](../benchmarks/2026-08-30-lightweight-diarization-cpu.md)).
Вводить еще один `официальный порог модели` оснований нет: исходные рецепты
публикуют параметры конкретного конвейера кластеризации, а не переносимую
границу для метода полной связи в sherpa. Вариант остается контрольным, но не
решает тикет.
### 2. Счетчик eigengap
Официальный CAMPPlus-конвейер 3D-Speaker строит матрицу косинусного сходства.
В каждой строке он оставляет фиксированную долю ближайших соседей,
симметризует матрицу, затем вычисляет ненормированный лапласиан и несколько
наименьших собственных пар.
Число говорящих задает позиция максимального разрыва собственных значений
(`eigengap`) в заданных пределах, после чего собственные векторы
кластеризуются k-means
([`SpectralCluster`](https://github.com/modelscope/3D-Speaker/blob/065629c313eaf1a01c65c640c46d77e61e9607b4/speakerlab/process/cluster.py#L21-L106)).
Для нашей двухшаговой схемы нужны только собственные значения и оценка `N`;
k-means можно не выполнять. Одного спектрального разложения достаточно для
минимального контрольного варианта, но фиксированный `pval` остается новым
порогом. Официальная реализация задает его для своего способа нарезки; пары
`окно x локальный говорящий` sherpa устроены иначе. Вариант отделяет `N` от
порога AHC, но сохраняет калибруемый параметр.
Если брать реализацию 3D-Speaker как зависимость, она импортирует NumPy, SciPy,
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 это несоразмерно счетчику.
### 3. NME-SC
NME-SC создан именно для совместной оценки числа кластеров и параметра
прореживания без отдельной выборки для настройки. Для каждого числа соседей
`p` алгоритм бинаризует матрицу косинусного сходства, симметризует ее, строит
ненормированный лапласиан и считает разрывы собственных значений. Нормированный
разрыв равен максимальному разрыву, деленному на наибольшее собственное
значение; выбирается `p`, минимизирующий отношение доли соседей `p/M` к
нормированному разрыву, а `N` задает позиция максимального разрыва
([статья авторов, раздел III](https://arxiv.org/abs/2003.02405)).
Авторы ограничивали поиск `p` диапазоном до четверти числа строк, а число
говорящих числом восемь. Результаты статьи получены на x-vector и англоязычных
корпусах, поэтому перенос на наши эмбеддинги еще предстоит проверить.
Поддерживаемая реализация 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` еще раз раскладывает лапласиан, строит
спектральные эмбеддинги и запускает k-means. Для этого тикета такая замена
итогового кластеризатора не нужна: локальные тесты уже показывают полезный путь
`FastClustering` с известным числом говорящих. Нужна только функция
`embeddings -> N`.
## Рекомендуемая граница ответственности
Здесь речь не об отдельном счетчике, а о способе соединить оценку `N` со
штатным кластеризатором:
```text
segmentation -> embeddings +-> счетчик NME -> N
+-> FastClustering(features, num_clusters=N)
-> штатная финализация sherpa
```
Плюсы:
- порог метода полной связи больше не определяет число голосовых кластеров;
- CAMPPlus и TitaNet используют один модельный проход;
- итоговое назначение меток остается в уже принятом `FastClustering`;
- на одной матрице можно независимо сравнить несколько счетчиков.
Минус один, но архитектурный: без нового промежуточного интерфейса текущий
`process()` не позволяет вставить счетчик между вычислением матрицы и
кластеризацией.
## Зависимости, лицензии и цена
Штатная установка NeMo требует среди прочего NumPy, scikit-learn и PyTorch
([`pyproject.toml`](https://github.com/NVIDIA-NeMo/Speech/blob/d47d7a3c306790637aff39c2d3f9eceb83ca8879/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` избыточна.
В проектных замерах основной расход пока приходится на извлечение эмбеддингов,
а не на кластеризацию
([профиль стоимости](2026-08-14-diarization-cpu-cost.md),
[CPU-тест](../benchmarks/2026-08-30-lightweight-diarization-cpu.md)); это не
заменяет отдельного замера 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
([репозиторий авторов](https://github.com/tango4j/Auto-Tuning-Spectral-Clustering/tree/ae97f9be9c33b554205c7192c5137ce8f456c1b7)).
Файл кластеризации 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 после
исключения пересечений речи;
переносимость нельзя считать доказанной.
- Правильное `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](../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` и не расширяет продуктовый контракт.