Преамбула

Привет habr! Меня зовут Александр, я работаю в группе компаний СБЕР в качестве Product Owner-а (красивое название для малополезного менеджера) 3 речевых продуктов. На работе каждый из продуктов заточен под хардкорный high-load, с соответствующими требованиями к инфраструктуре и SRE команде и с соответствующим специализированным решением для распознавания речи (далее ASR – automatic speech recognition). Это неудивительно, ведь на разных проектах количество коммуникаций может доходить до десятков миллионов, а результаты нужно получать в real-time формате.

Промышленные системы превосходно выполняют специализированные задачи, достигая максимальной эффективности, но платят за это потерей гибкости. Это как автомобиль Formula-1, он способен развивать невероятную скорость на трассе, но абсолютно неудобен для ежедневных поездок на работу по городским улицам.

Так и родилась идея сделать что-то полегче - свой pet-проект, который можно было бы использовать как швейцарский армейский ножик (Swiss Army knife) для всего, что связано с распознаванием речи из коробки без докруток и допилов. Инструмент, который можно развернуть на обычной видеокарте (GPU), без сложных балансировщиков нагрузки и других атрибутов high-load прода - я назвал проект ASRmy Knife. Статья получилась довольно длинной, поэтому я решил ее разбить на несколько частей – в первой я расскажу про идею и выбор ASR, а также про мой опыт оптимизации Whisper – как я побеждал длинные аудио, галлюцинации и другие болячки.

Концепция инструмента 7 в 1

asrmy-knife.png
asrmy-knife.png

В корпоративном проде каждая система заточена под свой сценарий: где-то скорость, где-то качество, где-то объемы. В итоге вместо одного решения вырастает зоопарк сервисов, и простое «взять и распознать аудиофайл» превращается в квест с пайплайнами и настройками. Отсюда идея «швейцарского ножика» для ASR - инструмента, который умеет понемногу всё сразу. Не всегда идеально, но достаточно хорошо и универсально.

Без ТЗ результат ХЗ

Проект я решил писать сам – я довольно активно программировал, будучи аналитиком на продукте, так что навыков должно хватить, ну а ML – это мое хобби со времен университета, плюс, это отличный способ тряхнуть стариной, чтобы доказать себе, что я не только карточки в жире перекладывать умею.
Пройдемся по базовым сценариям, которые я бы хотел решать, чтобы сформировать экспресс-ТЗ и определиться со стеком технологий.

  1. Распознать аудиофайл «прям щас» - это самый простой сценарий, есть у меня запись созвона по зуму или 2-х часовая лекция – а я хочу превратить ее в текст. Как несложно догадаться, потребуется выбрать легкое и гибкое ASR решение для этого сценария.

  2. Второй кейс – продолжение первого, ну окей, получил я простыню текста – много букв, некрасиво. Непонятно, какие фразы принадлежат тому или иному говорящему, значит – нужно уметь различать спикеров в одном файле – т.е. диаризовать аудио, а это +1 в список требований.

  3. Едем далее, есть у меня 2 записи звонков, значит нужно прогнать сценарий «распознать прям щас» 2 раза – это не проблема. А если записей 10? А если 100? А если бы он вез патроны? Одним словом – писать отдельный скрипт для массового процессинга – это как раз то, чего я бы НЕ хотел делать, следовательно, нам нужно научить наше решение формировать очередь на распознавание на своей стороне.

  4. А что если я хочу, чтобы и на винде и на линуксе и на калькуляторе и на 5 GB видеопамяти и на RTX 3090 запускалось? Много хочу, несомненно. Раз уж мы решили что нужно и речь распознавать и диаризовать и уметь пачки записей обрабатывать – значит нужно подумать о параллелизме и нагрузке, следовательно, нужно предусмотреть настройки, которые позволят горизонтально масштабироваться.

Сценарии для использования понятны, теперь можно составить план работ.

Выбираем ASR, по критериям легкости (т.е. один инстанс распознавания не должен стоить 20 гигов видеопамяти) и качества (в сфере распознавания речи – стандартная метрика качества это WER). Немного о том, как точно замерить качество модели:

wer  formula.png
wer formula.png

WER (word error rate) считает кол-во операций, которые необходимо сделать, чтобы привести исходную строку к оригиналу, своего рода «насколько мы далеки от бога идеальной транскрипции». Рассмотрим небольшой пример ниже, у нас есть эталон и то, как речь распознала модель ASR:

Эталон (reference): «я принесу молоток и гвозди завтра» N = 6
Распознано ASR (hypothesis): «я принесу молоко гвоздь завтра завтра»

Эталон

Распознанное

** Проверка**

я

я

ОК, корректно

принесу

принесу

ОК, корректно

молоток

молоко

S = 1 (подстановка)

и

(пропало)

D = 1 (удаление)

гвозди

гвоздь

S = 1 (подстановка)

завтра

завтра + лишнее завтра

I = 1 (вставка)

Итого: S=2, D=1, I=1, N=6.

image.png
image.png

Чем меньше WER – тем лучше. Сама метрика, кстати, предмет холивара. Дело в том, что ошибки формата:

гвозди → гвоздь (1 символ, не критично для восприятия)
гвозди → возить (вообще другое слово, меняет смысл)

одинаково считаются за S = 1, что делает эту метрику не самой объективной, но для моей задачи она сгодится.

Далее выбираем модель диаризации. Кстати, что это? Диаризация – это такой алгоритм, который:

  1. Берет 1 аудиодорожку, где есть несколько говорящих, шинкует ее на небольшие кусочки (chunk-и) например, по 0.5–2 секунды.

  2. Каждый кусочек превращается в «числовой отпечаток голоса» - вектор (например, из 192 чисел), который отражает особенности речи говорящего.

  3. Эти векторы можно нарисовать в виде точек в пространстве. Точки, принадлежащие одному человеку, группируются вместе.

  4. Так появляются кластеры: кластер А - Саша, кластер B - Аня, и т. д.

  5. Количество разных «кучек» точек, это количество разных голосов в записи.

Кластеры спикеров.png
Кластеры спикеров.png

Технически алгоритм сам умеет находить оптимальное число кластеров, исходя из количества «кучек» (это небольшое упрощение, не бейте). Однако, последние не всегда собираются в явном виде (как кластер «Паша» на картинке) – эту проблему будем побеждать дальше.

И еще пару небольших требований:

  • Полученное решение должно иметь поддержку очереди «из коробки», но при этом решение должно быть легкое – следовательно, нам нужно сделать «легкую» очередь.

  • На чем пишем код? Бекграунд у меня ML-ный, поэтому код писать будем на клавиатуре… питоне. Поехали!

Главное не размер, а что? (Выбираем ASR для ножика)

Effective Length.png
Effective Length.png

Как оказалось, размер – не главное, это я все еще про ASR, если что… Но не буду забегать вперед. Итак, вы, наверное, слышали про такую компанию как OpenAI – местами, первая часть названия даже оправдана (а местами нет), т.к. их модель для распознавания речи - это умный и неприхотливый вариант, который неплохо работает из коробки и знает много языков, а самое приятное – это опенсорс github.

Многие активно тестировали эту модель как только она стала доступной на просторах интернета в 2023 году (v3), на тот момент всего в несколько строк можно было развернуть модель whisper large всего на 10 гигабайтах видеопамяти и получить весьма неплохую транскрипцию, хоть и небыстро. Приятным бонусом было и остается то, что качество распознавания русского языка высокое, ниже данные бенчмарка, великий и могучий входит в десятку:

image.png
image.png

Однако, такая модель по производительности и рядом не стояла с продовыми коммерческими решениями – сравните сами, например, наша Kaldi на работе тянет по 4 real-time потока на 1 ядро (ядро CPU, а у CPU за 50 тыс. рублей их целых 24 и даже GPU не нужен, Карл!). Тут же требуется видеокарта, скорость распознавания приблизительно 1:1 (десять минут аудио распознается 10 мин) на каждый инстанс.

Все изменилось через несколько месяцев после выхода первых whisper, люди стали активно играть с моделью и экспериментировать с архитектурой нейросети. Кто-то решил взять и вместо 32 слоев декодера (если коротко, слой декодера - дорогая по вычислениям часть трансформерной нейросети) оставить всего 4.

Оказалось, что такой подход ускоряет модель в 8 раз, а в памяти она занимает уже не 10 гигабайт, а всего около 6. Т.е. теперь мы можем в одну обычную видеокарту за 50 тыс. уместить 4 распознавали, каждая из которых распознает с коэффициентом 1:8, это уже похоже на что-то серьезное. Теперь главный вопрос – а с качеством что? Оно упало незначительно, на 1 – 1.5% по моим замерам, это мизерная цена за 1600% прирост производительности.

А что другие модели? Ведь есть еще whisper tiny, base, small и medium – их рассматривать смысла нет т.к. при уменьшении размера модели линейно терялась скорость в обмен на качество – в отличие от whisper turbo. Картинки отсюда

image.png
image.png

TL DR; Выбор очевиден – берем whisper turbo и начинаем тестировать. Поначалу все идет хорошо, пока мы не сталкиваемся с Димой… Ни с того ни с сего в распознанном тексте мы начинаем натыкаться на какие-то непонятные фразы, самая частая из которых - «Субтитры сделал Dimatorzok».

Проблема 1: «Субтитры сделал Dimatorzok» - лечим галлюцинации Whisper.

Модель Whisper, как я писал выше, весьма умная. Она трансформерная, следовательно, распознает речь опираясь на контекст, ровно так же, как это делает человеческое ухо: если нас просят «принеси г**(р,в)оздь» и мы не расслышали последнее слово, то глядя на человека с молотком мы сами додумаем из контекста, что речь именно о гв**озде.

Вероятно, как и у мудрых людей от перенасыщенности мозгами со временем слегка съезжает крыша, так и наша модель порой теряет контакт с действительностью и начинает творчески фантазировать и галлюцинировать. В контексте трансформерных нейросетей галлюцинацией принято называть случаи, когда модель «придумывает» нечто, чего нет. LLM может придумать несуществующую страну или неверный факт, а вот модель ASR может услышать то, чего не было в аудио. Одним словом, горе от ума – нейросетевая версия.

По моим наблюдениям, особенно серьезно whisper галлюцинирует от тишины и музыки, это связано с тем, что OpenAI обучает свои модели на данных, которые доступны на просторах интернета в большом количестве – чтобы обучить модель ASR требуется аудиозапись, сопоставленная с текстом, в дикой природе аудиозапись + текст обитает в форме субтитров.

Вне дикой природы – например, на работе, мы используем целый штат людей-разметчиков, которые по очень скрупулёзным правилам, вручную, размечают звонки (т.е. создают субтитры) для обучения моделей. Причем, при таком подходе люди все равно могут допустить неточность, поэтому предусматривается кросс-валидация – проверка другим человеком, чтобы, не дай бог, опечатка не просочилась в датасет для обучения. Так вот, OpenAI умудряются обучаться на всем подряд – на субтитрах к фильмам, к дорамам, к аниме, в том числе и на субтитрах, за авторством Дмитрия Торзока. А как вы думаете – когда появляется заветная «Субтитры сделал Dimatorzok» - что происходит на экране? Правильно, идут титры на фоне тишины или музыки, следовательно, как Whisper будет распознавать тишину или музыку? Вот отсюда и растут ноги у половины галлюцинаций.

Ну хорошо, мы разобрались с причиной – а что дальше? Идем переобучать модель? Это не осо��енно хорошая идея т.к. потребует времени, железа, а главное, подготовки качественного датасета – для 1 человека это слишком сложное упражнение. Второй вариант – разобраться, а как работает инференс (+1 в копилку страшных слов, в широком смысле - это «процесс работы» или «результат работы» модели). Achtung – сейчас будет код, но не очень сложный, держитесь:

result = model.transcribe(audio, language, task="transcribe",
                    temperature=(0.0, 0.1, 0.15, 0.2),  # !!!
                    no_speech_threshold=NO_SPEECH, # !!!
                    avg_logprob= 0.5,  # !!!
                    suppress_tokens=[…],  # !!!
                    condition_on_previous_text=False,  # !!!
                    initial_prompt = ‘’, # !!!
                    compression_ratio_hallucination_threshold=HALLUCINATION_COMPRESSION,  # это уже моя придумка – о ней дальше)

Итак, этот метод транскрибирует аудио, прогоняя его через модель. Мы можем сразу задать модели язык транскрибации – единственное, модель может проигнорировать эту инструкцию.

Нам интересны параметры:

temperature,
no_speech_threshold,
suppress_tokens,
condition_on_previous_text
initial_prompt.

Под капотом реализована следующая логика работы (картинка ниже) – модель пытается распознать кусочек аудио с первым в списке параметром температуры, в моем примере это (0.0, 0.1, 0.15, 0.2). Температура – это степень «креатива» и нестабильности модели – после прогона получается текст и несколько доп. параметров:
compression_ratio – коэффициент компрессии
avg_logprob – уверенность в ответе
no_speech_prob – вероятность, что в этом сегменте тишина.

Далее эти значения сравниваются с пороговыми, которые мы задали. Например, мы хотим распознать кусочек аудио, logprob_threshold задаем 50% (т.е. если модель хоть на 50% уверена – то верим). При распознавании получаем decode_result.avg_logprob = 42% (т.е. модель только на 42% уверена, что правильно распознала речь) происходит fallback – модель еще раз пробует распознать этот сегмент со следующим параметром температуры (0.0, 0.1, 0.15, 0.2), если снова не проходим проверку, то 0.15, затем 0.2.

Итак, а что же происходит, когда модель пробует распознавание с последней температурой из списка и не проходит проверку – она просто берет и принимает последний ответ (да, с самой высокой температурой т.е. степенью неадекватности), как верный. И так сойдет!

алгоритм инференса.png
алгоритм инференса.png

Механизм фолбэка по температурам в Whisper показался мне странным: на последней, «горячей» температуре модель стабильно и уверенно выдаёт бред. Я решил переделать данный механизм, рассмотрим на примере compression_ratio. Это показатель сжимаемости текста: однообразная галлюцинация («ля-ля-ля…» или «Дима, дима, дима») сжимается отлично, значит - ratio высокое. У живой речи compression_ratio обычно держится около ~1–2, а у галлюцинаций улетает за 4–5 и легко за 10–20. Поэтому я ввёл порог compression_ratio_hallucination_threshold если значение выше - сегмент считаю тишиной и отбрасываю, технические детали и фикс я описал в PR на гитхабе, если хотите больше технической духоты – милости прошу в мой тикет в github.

Таким образом, Дима Торзок перестал фигурировать в транскрипциях, а количество галлюцинаций сократилось до минимального уровня. Помимо сложного, вышеописанного метода, я использовал и более простые: например некоторые токены, являющиеся атрибутами других славянских языков, можно подавить, снизив их вероятность через метод suppress_tokens – этим способом я тоже весьма активно воспользовался.

Еще одно важное наблюдение – это опасность использования initial_prompt для универсальных задач. Данный метод позволяет передать несколько слов или фраз в модель, чтобы она их лучше понимала – удобно, если мы транскрибируем аудио с узкопрофильной терминологией. Однако, это ломает универсальность модели «если в руках молоток – то везде мерещатся гвозди» - так вот, если initial_prompt = “молоток”, то и «молоко» и «лоток» и «поводок» рискуют некорректно распознаться.

Проблема 2: лечим СДВГ (все еще речь про Whisper) и ускоряемся.

Так получилось, что в первых тестах whisper turbo, даже после победы над галлюцинациями, я сталкивался со странными ошибками и зацикливаниями при однопоточной обработке аудио более 1 часа. Для воспроизведения данной проблемы, как несложно догадаться, нужно запустить транскрибацию и ждать… Долго… Я решил оставить эту проблему на десерт и поработать над параллелизмом – как я ранее писал whisper turbo шустрая модель, а в видеокарту залезает 4 распознавалки – логично было бы использовать весь ресурс для ускорения процесса.

Я реализовал легкий метод, который делил аудио на кол-во частей, равное доступным распознавалкам и, как легко догадаться, таким действием «победил» и косяки с длинными аудиофайлами. Для закрепления успеха, внутри каждого инстанса распознавания предусмотрел свое дробление на чанки, на случай процессинга 10 часового аудио. Добавило ли это оверхедов? По моим замерам, замедление по скорости составило менее 1%, так что решая проблему параллелизма мы решили и проблему «отсутствия усидчивости» нашей модели. Кстати, о последнем, насколько это настоящий параллелизм?

Проблема 3: Whisper, Python и True-параллелизм

Казалось бы, распараллелили - и полетели, но на самом деле – это был не совсем параллелизм. Всему виной Python. У него есть штука под названием GIL (Global Interpreter Lock) - назовем его, «диспетчер лифта»: даже если у вас несколько пассажиров, в кабину интерпретатора он пускает только одного за раз, остальные терпеливо стоят в очереди. GPU при этом считает все быстро, но всё обслуживание вокруг (нарезка аудио, I/O, склейка) упирается в этот «один лифт».

Возить пассажиров по одному - медленно. Я, по сути, ускорил процесс, увеличив число лифтов до 4, это неплохо, но, очевидно, что если пускать несколько людей в лифт за раз - доедем в разы быстрее. В терминах Whisper это значит: не гонять по одному чанку, а скармливать модели сразу несколько 30-секундных окон одним вызовом. Для Python это по-прежнему один «заход» в лифт, а внутри - GPU распараллеливает матрицы сам, как ему и положено – такой подход называется batching.

Но есть нюанс! Батчинг – это продвинутая магия из области линейной алгебры и у нее есть цена:

  • Чтобы батчить по-настоящему, приходится идти через низкоуровневый вызов DecodingTask.run(), а не через удобный model.transcribe().

  • На низком уровне не возвращаются word-timestamps – т.е. время начала и конца каждого слова (модель не делает дополнительный пост-проход по токенам слов). Тайминги целых сегментов - да, тайминги слов - нет.

  • Из-за этого страдает диаризация: стыкуем спикеров мы обычно по словам, а не «целыми абзацами». Добавьте сюда 30-секундные окна и пограничные перекрытия - и без дополнительного выравнивания слова будут смешиваться в кашу.

Сейчас я в процессе поиска лекарства для вышеописанной проблемы, потому что мои эксперименты с батчингом показали слишком «вкусный» прирост скорости, мимо которого пройти я не могу. В линейной имплементации (без батчинга) у меня час аудио распознается за 4-5 ��инут - уже рабочая история. В батчинге скорость составляет менее 1 минуты за час аудио. То есть 4 инстанса на одной RTX 3090 теоретически вытянут 4 часа записи примерно за 1 минуту. Для задач формата «у нас много файлов, распознать нужно вчера» - это прям то, что доктор прописал.

Резюме первой части

Когда первая версия ножа заработала и Whisper turbo перестал чудить, галлюцинировать и спотыкаться на длинных файлах, я поймал себя на мысли - вычислительных мощностей у меня больше, чем задач.

Я вспомнил себя студентом. Лекции, конспекты на коленке, преподаватель ускоряется, а ты уже отстал на три слайда. Насколько проще было бы просто записывать всё, а потом иметь единый интерфейс для хранения записей, поиска по тексту и так далее. И чтобы это работало не как «отдал файл -> получил .txt», а как полноценный портал.

В какой-то момент стало очевидно: если инструмент уже есть - почему бы не сделать из него сервис? Бесплатный. Без рекламы, без «пробных 30 минут». Просто нормальный, человеческий ASR-портал в Рунете.

Почему бесплатно? Как когда-то сказал один человек, который сделал мое детство значительно веселее - «Детишки же играть хотят». Те детишки уже выросли. Теперь они в универах, на работе, в зумах и митапах. И им иногда нужно просто распознать аудио. (сложная отсылка)

Параллельно я бы немного подтянул веб-часть, освоил React и стал чуть менее тупым менеджером, который только карточки в Jira перекладывает. Иногда полезно самому собрать приложение сложнее калькулятора, чтобы лучше понимать, что именно ты требуешь от команды.

Так вот, сервис уже работает, назвал ��го GolosHub. Можно воспользоваться без регистрации в тестовом режиме или создать аккаунт - всё одинаково бесплатно. Потыкать можно тут

Что дальше

  • Будет часть 2. Во второй серии я расскажу о нюансах работы с диаризацией и об архитектуре сервисов, из которых состоит ASRmy Knife.

  • Batching в пайплайн. Параллельно продолжаю пилить батчинг - это позволит GPU работать как следует и ускорит распознавание в разы. Как только стабилизирую тайминги слов и аккуратно подружу их с диаризацией - выкачу обновление и поделюсь цифрами.

  • Сервис GolosHub. Создавал как демонстрационную площадку, а вышло добротное приложение. Реализуя web-интерфейс, я столкнулся с множеством неожиданных задач, включая такие как сетевые проблемы российских провайдеров, сложности с адаптацией под Mac-и, вызовы безопасности и т.д. Возможно, этот опыт заслуживает отдельного поста.

Спасибо, что дочитали - в комментариях традиционно можно рассказать, где я не прав, а где ну вообще не прав и «кто, вообще, в 2026 использует виспер», все приветствуется 😉