ML - это просто, говорили они
ML - это просто, говорили они

У меня есть хобби - я веду в tg каналплейлист "для тренировок". Я постоянно отслушиваю треки, большинство из которых публикуются там же в tg. Те, что "цепляют" меня - публикую

Каналу уже 8 лет, и за это время объемы музыки выросли кратно. Раньше это было способом скоротать время в дороге, но теперь чтобы найти музыкальную "жемчужину" нужно несколько часов сфокусированного прослушивания новинок. Встал выбор: забить или..

Однажды у меня возникла идея, что с моим музыкальным хобби мне могло бы помочь ML. БОльшая часть входящего материала не соответствует моему персональному фильтру. Если убрать ее, то я снова смогу делать подборку в фоне и получать удовольствие

Я не эксперт в ML, но задача вроде бы понятная - готовим датасет, берем модель, обучаем, приключение на 20 минут..

..сейчас, спустя год, когда мой pet-project наконец-то работает. Я смотрю на путь, который привел меня к этому результату. Даже не с точки зрения технологий(про ML лучше писать мастерам игры), а с точки зрения логики и эволюции решения глазами разработчика. Вот этим я и хочу поделиться

Часть 1. Постановка задачи

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

  2. Нужно не просто выбирать "похожее на то, что мне нравится" - фильтр должен оставаться открытым и пропускать новые звучания. Т.е. задача скорее "отсеивать шлак"

  3. Решение не должно быть привязано к вендору. Ни на одном стриминге нет всего контента, а тот что есть полностью им подконтролен - мне это не подходит. Т.к. большинство материала в tg, то и приложение было бы удобно организовать в виде бота

  4. Писать лучше на python. Во-первых, чтобы пользоваться развитой ML-экосистемой. Во-вторых, чтобы банально быстрее прототипировать - на то он и pet-project

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

Итого: собираем все треки для прослушивания в один tg-канал, затем пишем telethon-бота, который будет слушать этот канал и пересылать пользователю только те из них, которые проходят фильтр

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

Модель фильтрации идеально было бы дообучать при появлении нового трека в любом из лайк\дизлайк-каналов, но и ручной команды в боте "переобучить модель на текущих данных" вполне хватит

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

Часть 2. Как машина может "слушать". Выбираем фичи

Восприятие(звука в частности) - тема глубокая и философская. Здесь я ее раскрывать не буду. Скажу просто что на прикладном уровне нам доступно:

  • несколько интегральных фич: продолжительность, битрейт, абсолютные границы динамического и частотного диапазонов

  • различные свертки самого сигнала, такие как, например, MFCC

  • теоретически возможные производные фичи из сигнала: темп, тональность, в датасетах так же часто можно встретить штуки вроде speechiness, acousticness, valence и даже жанр

Практически для всех этих параметров имеет смысл семплирование плавающим окном по времени с последующим статистическим анализом

В релевантных статьях приемлемую точность обучения удается получать, используя только интегральные фичи и статистические агрегаты - мат.ожидания, дисперсию и т.п.

Попробуем взять(из librosa, об этом далее):

  • mfcc

  • chroma_cqt Constant-Q chromagram

  • chroma_cens - Chroma Energy Normalized for each frame

  • chroma_stft - Normalized energy for each chroma bin at each frame

  • zcr - zero-crossing rate of an audio for each frame

  • rmse - root-mean-square (RMS) value for each frame

  • spectral_centroid - mean frequency for each frame

  • spectral_bandwidth - frequency bandwidth for each frame

  • spectral_flatness - measure to quantify how much noise-like a sound is, as opposed to being tone-like

  • spectral_contrast - high contrast values generally correspond to clear, narrow-band signals, while low contrast values correspond to broad-band noise

  • spectral_rolloff - center frequency for a spectrogram bin such that at least roll_percent of the energy of the spectrum in this frame is contained in this bin and the bins below

  • tonnetz - project chroma features onto a 6-dimensional basis representing the perfect fifth, minor third, and major third

  • tempo

Так же для каждого параметра посчитаем статистику: среднее квадратическое отклонение, коэффициенты асимметрии, и эксцесса

Всего получается около 100 фич, но многие из них многомерные, поэтому в распакованном виде это несколько тысяч колонок(в зависимости от размера временного окна)

Часть 3. Наивная логика фильтра

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

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

Для определения жанра в открытом доступе уже есть много датасетов - например, spotify и даже некоторые компиляции

Надо только забрать доступные по id трека mp3(~30-секундные демки) с помощью небольшого скрипта

Попробуем поработать с этим датасетом

Часть 4. Пишем фреймворк процессинга треков

Но начать стоит с ядра для работы с данными - конвейера, который позволяет из папки с mp3-файлами получить готовый датасет и делает это:

  • отказоустойчиво - т.е. способен продолжать расчета после рестарта

  • эффективно с точки зрения потребления ресурсов, особенно RAM

Этот компонент точно понадобится при обучении модели фильтра, поэтому можно инвестировать в него сразу

Pet-project'ы позволяют пробовать новое. Для управления данными вместо привычного pandas, а polars, который заявляется как более эффективная альтернатива

Для работы с сигналом после изучения статей и материалов я остановился на librosa - не самый актуальный инструмент, но достаточно простой, умеет в нужные фичи умеет, и есть много примеров его использования

Для ML - scikit-learn как самый простой и понятный фреймворк

Основная концепция пайплайна процессинга - батчевая обработка и перситентные чекпоинты: обрабатываем данные пачками, каждую пачку персистим, при перезапуске восстанавливаемся с последней сохраненной пачки. Сразу скажу, что вызывать python-функции в пайплайне polars - боль, но собственными модулями для работы со звуком он пока не обзавелся, поэтому хвастаться перфомансом не буду. Но вот сама логика работы фреймворка - акцент именно на этапность процессинга меня порадовала. По сравнению с pandas, который ориентированностью на данные ломает мозг разработчику

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

Часть 5. Учимся вычислять жанры

Итак, запускаем наш пайплайн - готовим датасет, делим его на train и test, создаем XGB-модель, учим.. и ничего не получается! Точность практически не отличается от случайного выбора

И дело не в том, что у нас есть только 30-секундные сниппеты низкого качества и не в параметрах фич или модели или ее типе(все это я исследовал). Нас подвела интуиция: жанр - очень "грязное", плохо формализованное субъективное свойство

Беглый анализ датасета в разрезе жанров показывает, что жанрам несвойственна какая-либо структура:

  • близкая по звучанию музыка из разных, например, регионов обозначается разными жанрами

  • если композиция не вписывается в существующую сетку жанров - их просто комбинируют или придумывают. иногда это выделение - явно ничем не обоснованное и откровенно искуственное

  • существует куча экспериментальной музыки, которую отнести хоть к чему-то практически невозможно

композиция сочетает в себе черты нескольких жанров? штош.. олды поймут
композиция сочетает в себе черты нескольких жанров? штош.. олды поймут

В процессе исследования я вручную отфильтровал и кластеризовал все это безобразие, это позволило поднять точность, но она все еще была далека от удовлетворительной

Часть 6. Пусть работают роботы

Из эпопеи с жанрами можно сделать несколько выводов:

  1. Стоит попробовать реализовать фильтр без этого свойства - вряд ли оно будет супер точным и сильно поможет

  2. Реалистичное количество пригодных для классификации жанров где-то между 100 и 200

Про обучение модели фильтра на имеющихся фичах расскажу ниже, а пока давайте закончим с жанрами - потому что в конечном счете именно эта идея сработает, хотя и с доработками

Есть класс моделей "без учителя" - например, в кластеризации такая модель может сама размечать данные, получая на вход метрику расстояния и какие-то инварианты, которым должна соответствовать разметка: общее количество кластеров, предельный размер или какая-то еще метрика, вычисляемая для кластера. Давайте дадим такой модели наши ограничения, покрутим разные метрики, и пусть модель выведет нам свои "жанры". Логика их назначения будет нам до конца не известна, но она будет куда более объективна(если конечно наши фичи значимы). А затем на этих "жанрах" мы сможем обучить модель классификации

Такая разметка позволила поднять точность модели выше 0.8 при ~150 "жанрах"

Часть 7. Фильтр на "сырых" фичах

Успех кластеризации без учителя наводит на мысль, что нам удастся обойтись без жанров вовсе - напрямую классифицировать "лайк" и "дизлайк"

Датасет для фильтра на момент обучения включал 1300 треков категории "лайк", 300 - "дизлайк". Что ж: смешиваем, компенсируем диспропорцию классов весом, учим LinearBoostClassifier модель, получаем точность выше 0.8 и радостные идем собирать бота

Часть 8. Бот

Рассказывать как собрать telethon-бота, упаковать его в докер и задеплоить я тоже не буду - отмечу только важные технические детали

Агрегатор каналов - отдельный самостоятельный проект, рабочую версию которого я собрал на скорую руку, лишь бы скорее запустить основной функционал. Основная особенность - он требует авторизации под пользователем, что делает его инфраструктуру сложнее, чем обычно бывает у ботов в tg

Питон привязан к железу. Покрутить его на домашнем ARM-based NAS - не выйдет

Исполнение тяжелых операций - фильтрации и обучения модели я развязал через перситентную очередь и отдельные process-пулы. Это съедает кучу памяти(модель фильтра - не пушинка), но таков python-путь(возможно, что-то можно будет сделать на interpreter-пулах на 3.13, но это не точно)

Часть 9. Запуск

Итак, бот запущен, все нужные каналы настроены, модель обучена с хорошей точностью, начинаем фильтровать.. получаем очень много false negative(когда отбрасывается хороший трек), и идем думать, что же мы делаем не так

Дело в том, что задачи "определить точку в один из классов" и "определить, не является ли точка выбросом из единственного класса" - это разные задачи. И они тем меньше похожи, чем меньше данных для обучения. По нашим требованиям - нам нужна вторая, но пока что мы решали первую и всего на 2000 примеров

Сначала пробуем в лоб решить проблему подбором подходящей модели. Я пробовал многик комбинации из scikit-learn, ClustPy. При решении задачи поиска выбросов точность с 0.8+ снижается до 0.587, что в общем-то и демонстрирует наш текущий фильтр в проде

Значительно увеличить количество данных мы не можем: во-первых, датасет - жирный. К этому моменту ширина строки была около 4к фич, а во-вторых - датасет размечаю я, и делаю я это со скоростью десятков треков в неделю - не сотен

Соответственно, перед нами стоит 2 новых задачи: сжать\взять более сжатые фичи и все-таки научиться решать задачу поиска выбросов

Часть 10. Меняем фичи

В поисках более совершенного фреймворка для анализа аудио я наткнулся на модели essentia

Выглядит как то, что нужно - производные фичи куда компактнее и понятнее характеризуют треки. Да, за это приходится платить дополнительными зависимостями на pytorch(но он существует в версии для CPU), запуском дополнительных промежуточных моделей. Но если это позволит повысить точность, то это того стоит

Официально essentia давно не релизится, но исходный код живой и довольно легко собирается в пакет из сорцов

Ну что ж - пробуем. Переписываем фичи, берем следующие модели(для просмотра метаинформации к ссылке добавьте .json)

В имени модели первая часть раскрывает собственно фичу, а постфикс означает базовую модель, поверх фич которой эта фича вычисляется

Как видно, эти фичи описывают достаточно высокоуровневые характеристики - настроение, танцевальность, мелодичность.. Удивительно похожие на те фичи, которые мы видим в датасетах вроде spotify

Плюс сам фреймворк essentia еще предоставляет абстракцию extractor, которая вычисляет еще несколько более простых фич: тональность, темп..

Использование этого фреймворка позволило значительно сжать датасет и сделать его понятнее для ручного анализа

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

Часть 11. Ah, shit - here we go again. Работать все-таки должны роботы

Мы сжали фичи и теперь можем позволить себе куда более жирный датасет. Бонусом стало возможно использовать больше вариантов моделей, т.к. многие модели очень чувствительны к количеству входных фич

Но на текущих данных значительного прироста точности фильтрации это не дало. Точность определения выбросов не поднялась выше 0.63 несмотря ни на какие ухищрения

Чтобы продвинуться придется поменять саму логику решения. Очевидно, в пространстве наших фич построить аналитическую границу на доступных движках моделей - не получается. А что если вернуться к идее кластеризации без учителя? Только качество кластеризации будем оценивать не просто по метрике расстояния, а еще и по тому, насколько хорошо эта кластеризация позволяет покрыть целевую(дизлайк) категорию списком кластеров

То есть, если кластеризация размечает точки таким образом, что есть множество кластеров-"жанров" Kdl, состоящие из дизлайк-треков на Y%, и в совокупности эти кластера покрывают X% всех дизлайк-треков, то такая кластеризация тем больше нам нравится, чем ближе Y и X к 100%

Тогда для фильтра мы просто будем использовать модель кластеризации, чтобы отнести трек к "жанру", и если модель относит его к Kdl - мы будем откидывать его. Похоже на изначальную логику? Да, но такой подход дает важную степень свободы, которой(видимо) не хватало библиотечным классификаторам: теперь мы решаем задачу поиска выброса только относительно "жанров", которые мы бы хотели отбрасывать

Здесь важно отметить, что не у всех моделей кластеризации есть способность кластеризовать новую точку - выбирать тип модели стоит аккуратно

Однако, у такой метрики есть слабое место - легкое переобучение. Y и X будут равны 100%, если размер кластера будет равным единице, а количество кластеров - числу точек датасета. Поэтому метрику необходимо сбалансировать, поощряя модель за высокий процент покрытия наименьшим возможным количеством кластеров. Возможно, существует промышленная методика нормировки, но я просто вывел пару формул, которые показали приличный результат

Часть 12. Заключение

В итоге мой подход обеспечивает мне точность 0.78 - 0.83. И это совпадает с моими субъективными наблюдениями - я перестал проверять за ботом отброшенные треки, так как false negative(когда отброшен заслуживающий внимания трек) случаются достаточно редко. Все еще ощутимы false positive, но их уже достаточно мало, чтобы я не тратил на них много времени. Я думаю, по мере выравнивания количества точек обоих классов в датасете их тоже станет меньше. Так что я считаю это успехом

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

Я и подумать не мог, что в эпоху прайма машинного обучения и стримингов задачу персональных рекомендаций музыки придется решать практически с нуля. Все релевантные статьи или репозитории, которые мне удалось найти - либо студенческие, либо академические. И в лучшем случае направлены на то, чтобы сделать свой shazam, или распознавание речи, а не хоть сколько-нибудь годную рекомендательную систему музыки

Комментарии (0)