Рекомендательная лента почти любого музыкального сервиса оптимизируется под одну метрику — удержание. Чем больше вы слушаете то, что уже любите, тем лучше показатели engagement. С точки зрения бизнеса это логично. С точки зрения слушателя — это медленно сжимающийся пузырь из одних и тех же 10–15 артистов.
Мне захотелось не рассуждать об этом абстрактно, а измерить. Так появился OpenEar — open-source, privacy-first дашборд на Streamlit, который берёт публичную историю прослушиваний из ListenBrainz и считает по ней прозрачные, полностью объяснимые метрики музыкального разнообразия. Никакой чёрной коробки, никаких сохранённых токенов, никакого поведенческого профилирования — только формула, которую можно прочитать целиком за минуту и перепроверить на калькуляторе.
Код открыт: github.com/macabre3k/openear. Ниже — как это устроено внутри.
Почему ListenBrainz, а не Spotify
ListenBrainz — открытый проект MetaBrainz (тех же людей, что делают MusicBrainz), который хранит историю прослушиваний пользователей и отдаёт её через публичный REST API без OAuth и без токенов для чтения открытых профилей. Это ключевое архитектурное решение всего проекта: раз можно работать без авторизации пользователя, значит, можно спроектировать инструмент, которому в принципе нечего утекать. Нет access token — нет риска, что кто-то получит доступ к чужому Spotify-аккаунту через мой дашборд.
Метрики: как считается разнообразие
Всё считающее ядро укладывается примерно в 30 строк в src/openear/analytics.py. Никакого ML, никаких скрытых весов — обычная статистика.
Diversity Score
В основе — нормализованная энтропия Шеннона по количеству прослушиваний на артиста:
from math import log def normalised_entropy(counts): values = [v for v in counts if v > 0] total = sum(values) if total == 0 or len(values) <= 1: return 0.0 entropy = -sum((v / total) * log(v / total) for v in values) return entropy / log(len(values))
Смысл простой: энтропия Шеннона максимальна, когда прослушивания распределены равномерно между артистами, и падает к нулю, когда вы гоняете по кругу одного и того же исполнителя. Деление на log(n) — это нормировка: она делает показатель сравнимым между слушателем с историей на 20 артистов и слушателем с историей на 2000, иначе энтропия была бы несправедливо выше просто из-за размера каталога.
Итоговый Diversity Score — это взвешенная сумма энтропии и «сопротивления концентрации» (насколько прослушивания не сосредоточены на одном топ-артисте):
def analyse(artist_counts): total = sum(artist_counts.values()) entropy = normalised_entropy(artist_counts.values()) concentration = max(artist_counts.values()) / total diversity = 100 * (0.75 * entropy + 0.25 * (1 - concentration)) ...
75% веса — энтропия, 25% — штраф за доминирование одного артиста. Слушатель, у которого топ-1 исполнитель занимает половину всех прослушиваний, получит заметный штраф, даже если формально энтропия у него не самая низкая.
Discovery Score
Вторая метрика отвечает не на вопрос «насколько ровно распределены прослушивания», а на вопрос «часто ли вы открываете новое»:
unique_ratio = min(1.0, len(artist_counts) / max(1.0, total * 0.35)) long_tail = sum(c for c in artist_counts.values() if c <= 2) / total discovery = 100 * (0.55 * unique_ratio + 0.45 * long_tail) return diversity, discovery, concentration
unique_ratio — это широта охвата: сколько разных артистов приходится на объём прослушиваний (нормировано так, чтобы не расти бесконечно с ростом истории). long_tail — доля прослушиваний, приходящихся на артистов, которых вы слушали один-два раза за всё время. Это и есть операционное определение «открытия нового»: не разовый клик по рекомендации, а редкие, но настоящие прослушивания малознакомых исполнителей.
Проверка на синтетических данных
Чтобы не быть голословной, я прогнала ровно эту же функцию (скопированную 1 в 1, не пересказанную) на двух синтетических Zipf-распределениях — «типичный алгоритмический слушатель» с 22 артистами и жёстким перекосом (top-артист забирает почти половину прослушиваний) и «после Rediscovery Queue» с 85 артистами и длинным хвостом. Разница получилась наглядной: у концентрированного паттерна Diversity 59.6/100 и Discovery всего 6.3/100 при топ-артисте на 49.4% прослушиваний; у распределённого — Diversity 95.6/100, Discovery 28.4/100, топ-артист падает до 4.2%. Никакой магии — это ровно та же формула, которая считается в самом приложении, просто на контролируемых данных, чтобы было видно поведение метрики на двух крайних случаях.
Клиент ListenBrainz API
src/openear/listenbrainz.py — тонкий, но аккуратный клиент к публичному API. Два момента, которые казались мне важными:
Во-первых, пагинация с уважением к чужому серверу — между запросами страниц стоит пауза:
time.sleep(0.15) # не долбим публичный API без нужды
Во-вторых, свой User-Agent вместо дефолтного python-requests/... — правило хорошего тона для любого клиента к чужому open API, и явное указание на то, чей это трафик и с какой целью. Ошибки API оборачиваются в собственное исключение ListenBrainzError, чтобы Streamlit-слой мог показать пользователю понятное сообщение, а не трейсбек requests.
Демо без единого реального аккаунта
Мне не хотелось, чтобы для того, чтобы попробовать инструмент, человеку нужно было сначала завести аккаунт в ListenBrainz и накопить историю. Поэтому в src/openear/sample.py есть детерминированный генератор синтетической истории прослушиваний: словари ARTISTS и RARE_ARTISTS, из которых demo_listens() семплирует прослушивания через взвешенный random.choices, с фиксированным сидом — то есть один и тот же демо-профиль воспроизводится у всех, кто просто хочет посмотреть, как выглядит дашборд, без единого реального обращения к API.
Интерфейс
app.py — это Streamlit-приложение: сайдбар с настройками (ввод пользователя ListenBrainz или переключатель на демо-режим), строка из пяти ключевых метрик, столбчатая диаграмма распределения прослушиваний по артистам, прогресс-бары для Diversity/Discovery Score и отдельная секция — Rediscovery Queue.
Rediscovery Queue — это, пожалуй, самая практическая часть проекта: список артистов, которых вы слушали один-два раза за всю историю, отсортированный по давности последнего прослушивания. По сути — операционализация Discovery Score в конкретный, кликабельный список: не абстрактная цифра «у вас длинный хвост», а «вот эти конкретные пять исполнителей вы почти забыли — возможно, стоит вернуться».
Тесты, CI, Docker
Проект небольшой, но обвязка сделана по-взрослому:
pytest-сьют покрывает и статистическую логику (tests/test_analytics.py), и клиент API (tests/test_listenbrainz.py);GitHub Actions гоняет тесты на каждый push;
пакет собран в
src/-layout и ставится редактируемо черезpip install -e .;есть
Dockerfileиdocker-compose.yml—docker compose up --build, и дашборд поднят;лицензия MIT.
Приватность как архитектурное решение, а не приписка внизу
Хочу отдельно проговорить то, что для меня было не второстепенной галочкой, а исходным условием проектирования: OpenEar не запрашивает OAuth, не хранит access token, ничего не пишет на диск между сессиями. Всё, что приложение знает о вашей истории прослушиваний, живёт в оперативной памяти активной сессии Streamlit и исчезает, как только сессия завершается. Это не «мы удаляем данные по политике» — это «архитектурно негде их накапливать».
Что дальше
В планах — опциональное обогащение жанрами через MusicBrainz (с кэшированием и соблюдением рейт-лимитов) и экспорт анонимизированного отчёта, которым можно поделиться, не раскрывая сам список артистов.
Код, тесты и полный вывод формулы — в репозитории: github.com/macabre3k/openear. Если у вас есть опыт с метриками на основе энтропии, рекомендательными системами или Streamlit-приложениями в продакшене — было бы интересно узнать, что бы вы посчитали иначе.