Content-based (TF-IDF)

Каждый фильм описывается числовым вектором из жанров; похожесть — косинус между векторами. Мнения других пользователей не нужны.

Похожие на Toy Story (1995):

Загружаем данные

Теория простым языком

Content-based рекомендует «похожее на похожее»: если тебе зашёл фильм, найдём другие фильмы, похожие на него по описанию — у нас это жанры. Оценки других людей здесь не нужны: смотрим на карточку самого фильма.

Главная мысль

Чтобы находить «похожие» фильмы, надо сначала научиться измерять похожесть. А чтобы её измерить — описать каждый фильм числами. Разберём это по шагам, вводя слова по дороге.

Признак (фича) — свойство айтема, которым мы его описываем. У фильма это жанры («комедия», «боевик»), а в общем случае ещё и описание, актёры, студия — всё, что есть в карточке до первого зрителя.

Важное отличие от коллаборативной фильтрации: здесь нам не нужны взаимодействия других людей с этим фильмом. Нужны только его собственные признаки. Поэтому новый фильм, который ещё никто не оценил, мы всё равно можем рекомендовать — жанры у него есть с первого дня.

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

▸Почему появился content-based подход
БылоPopularity показывает одно и то же всем.
ПроблемаХочется учитывать вкус конкретного человека, но поведения других о нужном айтеме может быть мало или совсем нет (новый айтем).
ИдеяРекомендовать айтемы, похожие по содержанию на то, что пользователь уже любил.
Стало лучшеПерсонализация, работает для холодного айтема (есть признаки), рекомендации легко объяснить («похоже по жанру»).
Осталось слабымОднообразная выдача («ещё такое же»), зависимость от качества метаданных, нет коллективной мудрости.
ДальшеCollaborative filtering — использовать поведение многих пользователей, а не только признаки айтема.

Шаг 1. Превращаем фильм в числа

Компьютер не понимает слова «комедия», ему нужны числа. Поэтому каждому фильму сопоставляют список чисел — по одному на каждое возможное слово. Если слово есть у фильма — число больше нуля, если нет — ноль.

Вектор — просто список чисел фиксированной длины. «Вектор фильма» — это его описание, переведённое в числа.

Но не все слова одинаково полезны. Жанр «драма» есть у тысяч фильмов и почти ничего не говорит, а «Film-Noir» — редкий и потому информативный. Чтобы редкие слова весили больше, используют приём:

TF-IDF — способ расставить веса словам: часто встречающееся у фильма слово получает вес побольше (TF), но если это слово есть почти у всех фильмов — вес снова снижается (IDF). Итог: редкие меткие слова важнее банальных.

Шаг 2. Меряем похожесть

Теперь у каждого фильма есть вектор. Похожесть двух фильмов — это насколько их векторы «смотрят в одну сторону».

Косинусное сходство — насколько «в одну сторону» направлены два вектора, независимо от их длины. В общем случае это число от −1 до 1, но у нас все TF-IDF-веса неотрицательные, поэтому косинус не может стать отрицательным и лежит в диапазоне 0…1: 1 — фильмы очень похожи по описанию, 0 — ничего общего.
Пример. У «Истории игрушек» вектор — это «мультфильм + комедия + приключения». У «Истории игрушек 2» почти такой же набор → косинус близко к 1 → их и порекомендуем как похожие.

Шаг 3. Рекомендации лично тебе

«Похожие на один фильм» — это полдела. Чтобы советовать персонально, строят твой портрет из того, что тебе уже понравилось (оценки от 4 и выше) — низкие оценки в профиль не идут, иначе мы бы учили модель на том, что тебе как раз не зашло.

Профиль пользователя — усреднённый вектор всех фильмов, которые человек высоко оценил. Грубо говоря, «средний фильм твоей мечты» в числах. Дальше ищем реальные фильмы, чьи векторы ближе всего к этому профилю.
Псевдокод
# офлайн: один раз превращаем фильмы в векторы
для каждого фильма i:
    v[i] = tf-idf(жанры фильма i)

recommend(пользователь u, K):
    liked = фильмы, которым u поставил оценку ≥ 4
    profile = среднее из нормированных v[i] по liked
    для каждого фильма i, не виденного u:
        score[i] = cosine(profile, v[i])
    вернуть K фильмов с наибольшим score
▸Формулы: TF-IDF, косинус, профиль

Классическая запись TF-IDF — та, что встречается в учебниках:

wt,d=tft,d⋅log⁡Ndftw_{t,d} = \mathrm{tf}_{t,d}\cdot \log\frac{N}{\mathrm{df}_t}

где tft,d\mathrm{tf}_{t,d} — как часто слово встречается у фильма, dft\mathrm{df}_t — у скольких фильмов оно есть вообще, NN — всего фильмов. Редкое слово (маленький dft\mathrm{df}_t) → большой log⁡\log → больший вес.

Но векторы на этой странице посчитаны не по ней. Мы берём TfidfVectorizer из TF-IDF-реализации scikit-learn в конфигурации по умолчанию, а у неё формула другая — со сглаживанием и добавленной единицей:

wt,d=tft,d⋅(ln⁡1+N1+dft+1),vd←vd∥vd∥2w_{t,d} = \mathrm{tf}_{t,d}\cdot\left(\ln\frac{1+N}{1+\mathrm{df}_t} + 1\right),\qquad v_d \leftarrow \frac{v_d}{\lVert v_d\rVert_2}

Что здесь делает +1+1, понятно и важно: слово, которое есть у всех фильмов, по первой формуле получает вес ровно 0 и исчезает из векторов совсем, а по второй остаётся с весом, равным одной только tf\mathrm{tf}. Затем вектор нормируется по L2L_2, поэтому косинус считается обычным скалярным произведением.

А вот smooth_idf содержательно не делает у нас ничего, и это стоит сказать прямо. Формально он считает idf\mathrm{idf} так, будто к коллекции добавлен ещё один документ, содержащий каждое слово разом. Объяснять его «защитой от деления на ноль» соблазнительно и неверно: у любого слова обученного словаря df≥1\mathrm{df}\ge 1 по построению, а незнакомое слово обученный векторизатор просто выбрасывает — делить тоже не на что. Весь его эффект здесь — чуть более сжатый диапазон весов. Мы его не трогали: это значение по умолчанию, и знать про него нужно ровно затем, чтобы повторить наши числа, — по учебничной формуле они не сойдутся. Точная конфигурация — в контракте обучения внизу страницы.

Косинусное сходство двух векторов:

cos⁡(a,b)=a⋅b∥a∥ ∥b∥=∑tatbt∑tat2 ∑tbt2\cos(a,b) = \frac{a\cdot b}{\lVert a\rVert\,\lVert b\rVert} = \frac{\sum_t a_t b_t}{\sqrt{\sum_t a_t^2}\,\sqrt{\sum_t b_t^2}}

Деление на длины ∥a∥,∥b∥\lVert a\rVert,\lVert b\rVert и делает меру независимой от длины описания. При неотрицательных весах числитель ≥ 0, поэтому cos⁡∈[0,1]\cos\in[0,1].

Профиль пользователя uu — среднее нормированных векторов понравившихся фильмов Lu={i:rui≥4}L_u=\{i: r_{ui}\ge 4\}:

pu=1∣Lu∣∑i∈Luvi∥vi∥p_u = \frac{1}{|L_u|}\sum_{i\in L_u}\frac{v_i}{\lVert v_i\rVert}

Теги: как выглядит утечка, когда её измеряешь

Загружаем данные
Загружаем данные

Главное в этой таблице — что честный вариант существует. У общего для всех вектора айтема единого «как есть на тот момент» и правда нет: протокол прячет у каждого пользователя его последнее событие, и одна общая отсечка тут не строится. Но признаки не обязаны быть общими — для каждого оцениваемого события можно собрать свой срез по его времени, и мы ровно это и посчитали. Такой замер дороже в сотни раз (по сборке векторов на событие) — а прироста не даёт почти никакого. Зато он показывает, чем на самом деле был прирост в нижней строке.

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

Побочный урок про сам метод виден там же, в строке про словарь: столько «содержания» у нас и есть, и именно на нём модель показывает свои метрики. Когда в статьях content-based выигрывает, за ним обычно стоят тексты описаний и эмбеддинги, а не список жанров.

Сильная сторона: холодный старт айтема

Холодный старт — ситуация, когда о ком-то/чём-то ещё нет данных. Холодный старт айтема — новый фильм, который никто не успел оценить.

Content-based с этим справляется: даже у только что вышедшего фильма есть жанры, а значит — вектор. Его можно рекомендовать сразу, не дожидаясь ни одной оценки. Это его главный козырь — и заодно ещё один довод против тегов: у фильма, который никто не смотрел, тегов нет по определению, так что модель на тегах этот козырь теряет ровно там, где он нужен.

Сильные стороны

  • Решает холодный старт айтема: новому фильму хватает его описания.
  • Объяснимость: «похоже по жанрам» — понятная причина рекомендации.
  • Не зависит от других пользователей — работает даже с одним человеком.

Слабые стороны

  • Однообразные списки: внутри выдачи фильмы похожи друг на друга — diversity@10 у content-based самая низкая из всех моделей курса (см. карточку метрик выше).
  • Качество ограничено качеством описаний: по одним жанрам легко промахнуться.
  • Слабая точность персональных топ-списков (это видно по метрикам на этой странице).

⚠️ Что может пойти не так

  • Качество = качество признаков: если у фильмов только жанры (как часто бывает), векторы грубые и похожесть смазана.
  • Путать «однообразно» с «популярно». У нашего content-based diversity@10 действительно самая низкая, а вот novelty@10 — самая ВЫСОКАЯ из всех моделей: он уходит в хвост каталога (и покрытие у него вчетверо выше, чем у item-CF). Это разные величины, и одна не выводится из другой.
  • «Пузырь фильтров» — гипотеза про ПЕТЛЮ: показали похожее → человек кликнул → модель ещё сузилась. Одной офлайн-выдачей она не проверяется: нужны динамика показов и реакция людей во времени. Как это вообще выглядит на симуляции — в модуле про логи и разметку.
  • Профиль усредняет всё подряд: у человека с очень разными вкусами «средний» вектор оказывается в пустоте между кластерами и плохо описывает любой из них.
  • Низкие оценки нельзя кидать в профиль наравне с высокими — иначе нелюбимое тянет рекомендации к себе.

🧠 Проверь себя: Почему новый фильм content-based может рекомендовать сразу, а коллаборативная фильтрация — нет?

Конфигурация обучения

Всё, что нужно, чтобы повторить числа на этой странице. Значения читаются из самих обученных моделей, а не набраны в вёрстке, — разойтись с кодом они не могут.

Загружаем данные

Что дальше

До финальной сборки не хватает ещё 11 модулей по этому пути.

Порядок здесь — рекомендация из карты курса, ничего не блокируется. Отметка «прочитано» хранится только в этом браузере.

Источники