Разбираем 3 реальные ошибки в алгоритмах рекомендаций на Python. Узнайте, почему Jaccard вредит ранжированию и как заменить его на recall. Примените паттерны в своём проекте.
Вы написали красивый матчинг на косинусной близости и Jaccard, но в продакшене результаты выглядят случайными? Знакомая ситуация. Я прошёл этот путь на платформе OTONAMI, которая подбирает независимых артистов для музыкальных кураторов. После серии провалов родились три простых, но критических решения. Они превратили демо-алгоритм в рабочий инструмент. Разберём каждое на примерах кода на Python.
Матчинг трека и куратора считается по взвешенной сумме трёх суб-скорров:
score = genre_score * w_genre + mood_score * w_mood + audio_score * w_audioКаждый суб-скорр лежит в диапазоне [0, 1]. Жанр — самый сильный предиктор, настроение — вторичный сигнал, аудио — скорее тай-брейкер. Разумные веса: 0.55 / 0.25 / 0.20. Но главное — не веса, а то, как считаются суб-скорры.
Интуиция подсказывает использовать Jaccard similarity: пересечение множеств жанров трека и куратора, делённое на объединение. Симметрично, математически красиво. И абсолютно неправильно для этой задачи.
Представьте куратора, который принимает 10 жанров — широкий профиль. Трек попадает ровно в один из них. Это идеальное совпадение! Но Jaccard даст 1 / 10 = 0.1, потому что объединение огромно. Чем шире куратор, тем сильнее Jaccard его наказывает. Полная противоположность нужному поведению.
Вопрос не симметричен. Он звучит так: попадает ли трек в жанровую нишу куратора? Поэтому жанровый скорр — это recall по жанрам трека: какая доля жанров трека покрыта куратором.
def genre_score(track_genres: list[str], curator_genres: list[str], open_to_all: bool) -> float:
if open_to_all:
return 1.0
track_set = set(normalize(track_genres))
if not track_set:
return 0.5 # см. Решение 3
curator_set = set(normalize(curator_genres))
covered = len(track_set & curator_set)
return covered / len(track_set)Широкий куратор больше не наказывается за широту. Асимметрия реального вопроса встроена в метрику.
Аудио-скорр — это косинусная близость по вектору признаков: energy, danceability, acousticness, instrumentalness, valence. Шестое измерение, которое напрашивается — tempo. Я его исключил сознательно.
Автоматическое определение BPM ненадёжно и разрушительно для метрики расстояния: оно систематически ошибается в два раза (half-time/double-time). Медленная баллада на 60 BPM часто читается как 120 BPM. Когда это удвоенное значение попадает в вектор и вы вычисляете расстояние, оно не просто добавляет шум — оно пробивает дыру в скоре. Два трека, которые должны быть рядом, вдруг оказываются далеки по одной оси.
В ранней версии это вызывало баг: около 22% матчей схлопывались в плоский, бессмысленный скор. Почти всегда виноват был темп. Удаление темпа из скоринга сразу убрало целый класс ложных отрицаний.
def cosine_similarity(a: dict, b: dict, dimensions: list[str]) -> float:
dot = 0.0
mag_a = 0.0
mag_b = 0.0
for dim in dimensions:
x = a.get(dim, 0)
y = b.get(dim, 0)
dot += x * y
mag_a += x * x
mag_b += y * y
if mag_a == 0 or mag_b == 0:
return 0.0
return dot / (mag_a ** 0.5 * mag_b ** 0.5)
# Используем только проверенные измерения
AUDIO_DIMS = ['energy', 'danceability', 'acousticness', 'instrumentalness', 'valence']Темп может показываться в UI для человека («похожая энергия и темп»), но никогда не должен влиять на скор. Держите объяснение и скоринг разделёнными.
Независимая музыка полна пробелов. У многих треков нет надёжного аудиоанализа. Многие кураторы не заполнили теги настроения. Наивное решение — считать пропуск за ноль — тихо хоронит каждого нового или малоизученного артиста на дне рейтинга.
Поэтому, когда с какой-либо стороны нет аудио или настроений, этот фактор возвращает нейтральное значение 0.5. Оно не помогает и не вредит.
def audio_score(track_audio: dict | None, curator_audio: dict | None) -> float:
if not track_audio or not curator_audio:
return 0.5 # отсутствие сигнала — не evidence плохого совпадения
return cosine_similarity(track_audio, curator_audio, AUDIO_DIMS)Отсутствие доказательств — не доказательство плохого соответствия. Эта одна строка защищает самых новых артистов, которых и должна находить платформа для открытий.
Ни одно из этих решений не является хитрым алгоритмом. Это маленькие, скучные страховки: recall вместо Jaccard, одно удалённое измерение, один нейтральный fallback. Но каждое родилось из наблюдения за реальными ранжированиями, которые шли не так. Вместе они составляют основную разницу между матчером, который работает, и тем, который просто выполняется.
Ваше задание на сегодня:
Полный код библиотеки с типизацией доступен в репозитории music-matching-patterns. Применяйте эти паттерны в своих рекомендательных системах и не повторяйте чужих ошибок.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →