Вечер. Пересматриваю «Пятницу 13». Не люблю пересматривать фильмы, даже хорошие. Но выбрать интересное кино из потока новинок сложно. Поэтому мне захотелось написать свой рекомендатор кино. Этим и займусь в выходные.
В статье покажу, что получилось написать за 2 дня. Писал всё «на коленке» по доступным библиотекам и данным. Получилcя DIY-рецепт. Всё платформозависимое работает в Docker, чтобы повторить и развернуть можно было везде.
Определимся с деталями проекта
Движки рекомендаций строят выдачу двумя способами: по оценкам пользователей и по содержимому. При первом подходе система кластеризует пользователей по интересам. В ленту попадают фильмы, которые нравятся пользователям кластера. Для второго подхода система выделяет признаки из информации о фильмах и предлагает похожие.
Пользователь у меня один, поэтому кластерный подход не сработает. Буду реализовывать рекомендации по содержанию.
Основные задачи
Накидаю задач для проекта:
найти датасет с информацией о кино. Чем больше данных, тем лучше;
векторизовать фильмы для сравнения и поиска;
настроить векторную базу данных;
написать интерфейс на Flask.
Писать я буду на Python, ведь на нём удобно работать с векторами и датасетами.
Поиск датасета фильмов
На поиск датасета я потратил 3 часа. Перебрал 10 вариантов. В одних наборах данных было меньше 1000 строк, а другие не содержали важных колонок: описания, актёров, жанров.
Сначала я нашёл официальные датасеты imdb.com, но они разделены на 4 файла. Их нужно мержить по ID в одну таблицу. У файла с работниками нелинейная структура: много строчек на 1 фильм. Для мержа надо самостоятельно отделить и сгруппировать актёров. Колонки с описаниями в официальном датасете нет.
Далее я искал данные на Kaggle и Github. Остановился на TMDB + IMDB Movies Dataset 2024. Это датасет в CSV-формате на 1 млн. строк. В нём 27 колонок: название, актёры, рекламные слоганы, описания, жанры и другие. Его я распаковал в movies.csv.
Для тестов я решил оставить только фильмы с известными актёрами. Для фильтрации использовал датасет Top 100 Greatest Hollywood Actors of All Time.
Готовим данные
Перед использованием нужно почистить датасет: удалить строки без данных, отфильтровать фильмы по статусу и сборам. Оставшиеся колонки с NaN — не критичные, поэтому их я заполняю значениями подходящего типа.
$ python -m venv env
$ . ./env/bin/activate
$ pip install pandas
import pandas as pd
movies = pd.read_csv(
'movies.csv',
usecols=['id', 'title', 'release_date', 'revenue', 'status',
'imdb_id', 'original_language', 'original_title', 'overview',
'tagline', 'genres', 'production_companies',
'production_countries',
'spoken_languages', 'cast', 'director', 'writers',
'imdb_rating', 'imdb_votes'
]
)
# Вырежем строки с пустыми колонками
movies.dropna(subset=[
'title', 'overview', 'genres', 'release_date', 'status',
'cast', 'director', 'writers', 'imdb_id'],
inplace=True
)
# Оставим фильмы, которые уже вышли и что-то заработали
movies = movies[movies['status'] == 'Released']
movies = movies[movies['revenue'] > 0]
movies.drop(['status', 'revenue'], axis=1, inplace=True)
movies.reset_index(drop=True, inplace=True)
# Заполняем нулями пустые рейтинги и отзывы
movies.fillna({'imdb_rating': 0, 'imdb_votes': 0}, inplace=True)
# Заполняем пропущенные слоганы пустотой строкой
movies['tagline'] = movies['tagline'].fillna('')
Теперь загружу топ актёров. Сначала из даты рождения я выделю год. Потом отсортирую всех по нему в порядке убывания и получу имена первых 50 актёров. Преобразую их в set
-множество.
actors = pd.read_csv('actors.csv')
# Выделяем год рождения
actors['Year of Birth'] = actors['Date of Birth'].apply(
lambda d: d.split()[-1]
)
# Сортируем по году рождения и берём первых 50 актёров
actors.sort_values('Year of Birth', ascending=False, inplace=True)
actor_set = set(actors.head(50)['Name'])
Создам отдельный датафрейм для отфильтрованных фильмов. В него запишу все фильмы, у которых список актёров пересекается с set
-множеством из ТОПа.
def actors_intersect(actors: str):
"""Проверяем пересечение актёров."""
actors = set(actors.split(', '))
return bool(actors.intersection(actor_set))
# Отберём фильмы для теста
movies['to_test'] = movies['cast'].apply(actors_intersect)
df = movies[movies['to_test']]
df.reset_index(drop=True, inplace=True)
В тестовый набор попал 1981 фильм.
Объединим франшизы
Некоторые фильмы выпускаются в рамках франшиз. Например, «Мстители» и «Звёздные войны». Такие фильмы должны попадать в рекомендации вместе. Картины одной серии выпускают с похожими названиями. Связать их можно кластеризацией.
Для кластеризации нужны векторы. Чтобы не тратить лишнего времени, нужен быстрый алгоритм векторизации. Я взял TfidfVectorizer из пакета sklearn.
Для TfIdf я задал английский словарь стоп-слов, чтобы исключить служебные части речи. Также в параметрах я ограничил вхождения токена min_df
и max_df
.
Кластеризовать я буду через DBSCAN. Он борется с шумом и может работать с «плотными» облаками точек. А главное — для него не надо заранее знать количество кластеров.
from sklearn.cluster import DBSCAN
from sklearn.feature_extraction.text import TfidfVectorizer
tfidf = TfidfVectorizer(stop_words='english', min_df=2, max_df=0.2)
matrix = tfidf.fit_transform(df['title'])
dbscan = DBSCAN(eps=0.9, min_samples=2)
df['title_cl'] = dbscan.fit_predict(matrix).astype('str')
Выделяем именованные сущности
В заголовках, слоганах и описаниях можно найти именованные сущности. Например, известные локации, города, страны, персонажи, известные люди и важные даты. По таким сущностям можно связывать фильмы с похожим содержанием.
Выделять сущности я буду через spaCy. Для работы spaCy скачаем англоязычную модель en_core_web_sm, которую заранее обучили на текстах из интернета.
$ pip install spacy
$ python -m spacy download en_core_web_sm
Теперь выделим сущности из колонок title, tagline, overview.
import spacy
nlp = spacy.load('en_core_web_sm')
def extract_ents(row):
"""Соберём вместе три колонки и выделим сущности."""
text = row['title'] + '. ' + row['tagline'] + '. ' + row['overview']
ents = nlp(text).ents
return ', '.join(set([ent.text.lower() for ent in ents]))
# В колонку ents запишем список сущностей
df['ents'] = df.apply(extract_ents, axis=1)
Категориальные данные
В датафрейме есть категориальные данные — данные с ограниченным числом значений. Это жанры, режиссёры, сценаристы, именованные сущности и кластеры названий. Их я также использую для рекомендаций.
В датафрейме в поле cast указано множество актёров, но выделить главные роли не получится. Векторы для всего списка актёров получаются огромными. Поэтому от обработки актёров на данном этапе я отказался.
Для векторизации категориальных данных я снова использую векторизатор TF-IDF. Обычно его не применяют на категориальных данных. Но он гибкий и у него много параметров. Можно контролировать размер выходного вектора, отсеять признаки по частоте. Алгоритм IDF даст редким признакам больше веса, чем часто встречающимся.
Фильмы из одной франшизы с похожим набором сущностей должны быть выше в списке рекомендаций. На них я выделю больше значений в результирующем векторе. А сценаристам и режиссёрам значений дам меньше. Для векторизатора задам разные настройки min_df в зависимости от категориального параметра. А длину вектора вычислю динамически по количеству признаков:
для сценаристов минимум 4 вхождения, длина вектора не более 10% от словаря признаков;
для режиссёров минимум 2 вхождения и длина не более 20%;
франшизы и сущности без ограничений.
cat_tfidf_args = {
'tokenizer': lambda cats: [c.strip() for c in cats.split(',')],
'token_pattern': None,
}
categories_cols = [
('writers', 4, 0.1),
('director', 2, 0.2),
('title_cl', 1, 1),
('ents', 1, 1),
]
for col, min_df, coeff in categories_cols:
tfidf = TfidfVectorizer(min_df=min_df, **cat_tfidf_args)
# Определим число признаков и ограничим длину вектора
tfidf.fit(df[col])
tfidf.max_features = int(len(tfidf.vocabulary_) * coeff)
# Запишем вектор в датафрейм
df[col + '_vec'] = tfidf.fit_transform(df[col]).toarray().tolist()
В тестовом датасете всего 19 уникальных жанров, но они идут наборами с разной длиной. На таких данных TF-IDF сработает неэффективно. Поэтому я применяю MultiLabelBinarizer. Он вернёт для каждой строчки вектор из 19 значений.
from sklearn.preprocessing import MultiLabelBinarizer
mlb = MultiLabelBinarizer()
# На вход MultiLabelBinarizer нужно передать список
# Поэтому строку с жанрами разделим по запятой
genres = df['genres'].apply(
lambda row: [g.strip() for g in row.split(',')]
)
df['genres_vec'] = mlb.fit_transform(genres).tolist()
Векторизуем описания
Чтобы связывать фильмы по описаниям, нужно векторизовать колонку overview. Для этого я использую модель ROBERTA — переученный BERT от Google с оптимизацией. Поэтому она векторизует лучше оригинала. На Хабре есть статья о различиях BERT'а и ROBERTA. Я возьму transformers от hugging face для работы с моделью. Для transformers нужен бэкэнд, поэтому поставлю ещё и torch.
Перед запуском модели надо посчитать входные данные. Это делает токенизатор. Я указал ему параметр return_tensors на “pt”, чтобы конечные тензоры были в формате PyTorch. Через truncation и max_length я ограничил входные данные до 512 токенов.
Я запущу модель в контексте no_grad. Это отключит расчёт градиента обратного распространения. Его используют во время обучения моделей, чтобы вычислять ошибку и править веса. Но сейчас я только запускаю модель, поэтому градиент мне не нужен.
$ pip install transformers torch
import torch
from transformers import RobertaModel, RobertaTokenizer
tokenizer = RobertaTokenizer.from_pretrained("roberta-base")
model = RobertaModel.from_pretrained("roberta-base")
def get_embed_text(text):
inputs = tokenizer(
text,
return_tensors="pt",
truncation=True,
max_length=512
)
with torch.no_grad():
out = model(**inputs)
return out.last_hidden_state.mean(axis=1).squeeze().detach().numpy()
df["overview_vec"] = df["overview"].apply(get_embed_text)
У меня видеокарта RTX 3050 и процессор Ryzen 3200G, поэтому для обработки текстов двух тысяч фильмов нужно 2-3 минуты. Обработка того же объёма текста только на процессоре занимает 7-10 минут.
Объединим векторы
Я векторизовал колонки датафрейма по отдельности. Теперь объединю их векторы через numpy. Размерность конечного вектора выведу на экран.
Датафрейм с векторами запишу в формате pickle в embedded.pkl:
import numpy as np
def concatenate(row, col_names):
"""Объединим векторы из колонок в один вектор фильма."""
embedding = np.concatenate(row[col_names].values)
embedding = np.concatenate((embedding, row['genres_vec']))
embedding = np.concatenate((embedding, row['overview_vec']))
return embedding
cat_col_names = [col + '_vec' for col, _, _ in categories_cols]
df.loc[:,'embedding'] = df.apply(lambda x: concatenate(x, cat_col_names), axis=1)
print('Embedding shape:', df['embedding'][0].shape)
df.to_pickle('embedded.pkl')
После запуска скрипта я получил для каждого фильма вектор размерностью 6399.
Векторный Postgres
Для хранения векторов я выбрал привычный PostgreSQL. Базу выбирал по критериям:
база должна работать и с векторами и с обычными данными. Например, для хранения «сырых» полей: названия, описания и рейтинга;
нужен поиск фильмов по названию.
Чтобы работать с векторами в постгрисе, потребуется расширение pgvector. Оно добавляет тип данных vector и реализует поиск ближайших векторов.
Важно: в pgvector размерность векторов должна быть менее 16 тысяч.
Далее я настроил контейнер для базы с Docker и Docker-compose. Вот содержимое файлов:
# psql.Dockerfile
FROM postgres:16-alpine3.20
RUN apk update && apk add --no-cache postgresql16-plpython3
RUN apk update; \
apk add --no-cache --virtual .vector-deps \
postgresql16-dev \
git \
build-base \
clang15 \
llvm15-dev \
llvm15; \
git clone https://github.com/pgvector/pgvector.git /build/pgvector; \
cd /build/pgvector; \
make; \
make install; \
apk del .vector-deps
COPY docker-entrypoint-initdb.d/* /docker-entrypoint-initdb.d/
# docker-compose.yml
version: '3'
services:
postgres:
build:
dockerfile: psql.Dockerfile
context: .
ports:
- 5432:5432
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
- POSTGRES_DB=db
volumes:
- pgdata:/var/lib/postgresql
volumes:
pgdata:
Создал инициализирующий SQL-скрипт для базы. В нём загрузил pgvector:
$ mkdir docker-entrypoint-initdb.d/
$ touch docker-entrypoint-initdb.d/init.sql
-- docker-entrypoint-initdb.d/init.sql
CREATE EXTENSION IF NOT EXISTS vector;
Проверяю работу: запускаю контейнеры через docker-compose и смотрю логи.
$ docker-compose up -d
$ docker-compose logs
Теперь нужно создать таблицу, чтобы сохранить в ней векторы фильмов. Я добавлю колонки для названия, рейтинга, описания и IMDb ID. Рекомендатор отобразит их в ленте. Для вектора я задам поле embedding. В pgvector размерность вектора нужно знать заранее. У меня получилась размерность вектора 6399 — эту информацию выдал скрипт обработки датафрейма с фильмами.
Добавлю создание таблицы в файл docker-entrypoint-initdb.d/init.sql.
-- docker-entrypoint-initdb.d/init.sql
...
CREATE TABLE movies (
tconst VARCHAR(16) PRIMARY KEY NOT NULL UNIQUE,
title VARCHAR(64) NOT NULL,
title_desc VARCHAR(4096) NOT NULL,
avg_vote NUMERIC NOT NULL DEFAULT 0.0,
embedding vector(6399)
);
После изменения init.sql нужно пересобрать Docker-образ и перезапустить базу. Это займёт не больше минуты, ведь Docker кэширует сборки.
$ docker-compose down && docker-compose build && docker-compose up -d
Векторы фильмов нужно загрузить в базу. Для этого я написал скрипт в отдельном файле, который берёт данные из embedded.pkl. Работать с базой я буду через библиотеку psycopg. А чтобы она работала с векторами, нужна библиотека pgvector-python.
$ pip install psycopg pgvector
# filldb.py
import asyncio
from pgvector.psycopg import register_vector_async
import pandas as pd
import psycopg
df = pd.read_pickle("embedded.pkl")
async def fill_db():
async with await psycopg.AsyncConnection.connect(
'postgresql://user:password@localhost:5432/db'
) as conn:
await register_vector_async(conn)
async with conn.cursor() as cur:
for _, row in df.iterrows():
await cur.execute(
"""
INSERT INTO movies (
tconst,
title,
title_desc,
avg_vote,
embedding
) VALUES (%s, %s, %s, %s, %s)
""",
(
row['imdb_id'],
row['title'],
row['overview'],
row['imdb_rating'],
row["embedding"],
),
)
asyncio.run(fill_db())
После запуска все векторы запишутся в базу и можно будет искать похожие фильмы.
$ python filldb.py
Интерфейс на Flask
Чтобы удобно пользоваться Рекомендатором, я сделал веб-интерфейс на Flask и Jinja.
$ pip install flask
Интерфейс состоит из одной страницы с полем ввода для названия фильма. Лента рекомендаций появляется ниже после отправки формы. Запрос и номер страницы передаю в GET-параметрах. На странице отображается по 20 фильмов. Для формы поиска я сделал подсказки: 20 случайных названий, которые вытащил из базы данных. Вместо постеров прикрутил случайные фотографии с собаками. Так выдача Рекомендатора смотрится веселее.
Код Flask приложения и Jinja шаблон привожу ниже. Вот файл app.py:
# app.py
import psycopg
from flask import Flask, render_template, request
from pgvector.psycopg import register_vector
app = Flask(__name__)
@app.route('/')
def main():
query = request.args.get('q')
page = max(0, request.args.get('p', 0, type=int))
with psycopg.connect(
'postgres://user:password@localhost:5432/db'
) as conn:
register_vector(conn)
with conn.cursor() as cur:
hints = cur.execute(
'SELECT title FROM movies ORDER BY random() LIMIT 20;'
)
if query is not None:
query = query.strip()
queryset = cur.execute(
"""
WITH selected_movie AS (
SELECT *
FROM movies
WHERE LOWER(title) = LOWER(%s)
LIMIT 1
)
SELECT
m2.*,
(SELECT COUNT(*) FROM movies) AS total_count,
selected_movie.embedding <-> m2.embedding AS euclidean_distance
FROM
movies m2,
selected_movie
ORDER BY
euclidean_distance ASC
LIMIT 20 OFFSET %s;
""",
(query, 20 * page),
)
result = queryset.fetchall()
num = result[0][5] if result else 0
return render_template(
'search.html',
query=query,
result=result,
page=page,
num=num,
hints=hints,
)
return render_template('search.html', hints=hints)
Вот файл шаблона страницы на Jinja2 templates/search.html:
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% if query %}
<title>{{ query }} - Рекомендатель кино ({{ num }})</title>
{% else %}
<title>Рекомендатель кино</title>
{% endif %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/light.min.css">
<style>
* {
box-sizing: border-box;
}
body {
max-width: 960px;
}
.app {
margin-top: 30%;
}
.app>h1 {
font-weight: normal;
font-size: 4.5rem;
margin-bottom: 1.5rem;
text-align: center;
}
.app>h1>a,
.app>h1>a:hover,
.app>h1>a:active,
.app>h1>a:focus,
.app>h1>a:visited {
color: #46178f;
text-decoration: none;
}
#search-box {
display: block;
width: 100%;
max-width: 700px;
margin: 0 auto;
padding: 1.25em;
border-radius: 8px;
background-color: hsl(0, 0%, 96%);
border: none !important;
outline: none !important;
font-family: sans-serif;
}
#search-box:focus {
box-shadow: 0px 0px 15px -2px #46178f !important;
}
.query-result {
margin-top: 3em;
width: 100%;
max-width: 100%;
overflow-x: auto;
}
.query-result>table {
width: 100%;
}
.query-result td {
padding-top: 1.5em;
padding-bottom: 1.5em;
}
.query-result td.rating {
vertical-align: middle;
text-align: center;
font-size: 1.5em;
}
.query-result th.special {
text-align: center;
width: 15%;
}
.query-result tr:nth-child(2) {
background-color: #46178f22;
}
.pagination {
font-size: x-large;
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<div class="app">
<h1><a href="/">Рекомендатель</a></h1>
<form action="/" method="get">
<input id="search-box" name="q" type="text" value="{{ query }}">
</form>
</div>
{% if query %}
{% if result %}
<div class="query-result">
<table>
<tr>
<th class="special">Рейтинг</th>
<th class="special">Постер</th>
<th>Описание</th>
</tr>
{% for movie in result %}
<tr>
<td class="rating">{{ movie[3] }}</td>
<td>
<img loading="lazy" decoding="async" src="https://placedog.net/149/209?id={{ loop.index }}" width="149" height="209" alt="">
</td>
<td>
<b>{{ movie[1] }}</b>
<p>{{ movie[2] }}</p>
<span>
<a href="/?q={{ movie[1]|urlencode }}">Искать похожие</a>
|
<a href="https://imdb.com/title/{{ movie[0] }}" target="_blank">Страничка на IMDb</a>
</span>
</td>
</tr>
{% endfor %}
</table>
</div>
<p class="pagination">
{% if page and page > 0 %}
<a href="/?q={{ query }}&p={{ page - 1 }}">{{ page }}</a>
{% endif %}
{{ page + 1 }}
{% if (result|length) == 20 %}
<a href="/?q={{ query }}&p={{ page + 1 }}">{{ page + 2 }}</a>
{% endif %}
</p>
{% else %}
<p>Результатов нет...</p>
{% endif %}
{% endif %}
</div>
<script type="text/javascript">
let searchBox = document.getElementById("search-box");
searchBox.addEventListener("keydown", event => {
if (event.key != "Enter") return;
let value = event.srcElement.value;
if (value.length == 0) {
event.preventDefault();
return;
}
});
const examples = [
{% for hint in hints %}
"{{ hint[0]|safe }}",
{% endfor %}
].map((example) => example += "...");
let exampleId = 0;
let letterId = 0;
let reversed = false;
function getRandomInt(max) {
return Math.floor(Math.random() * max);
}
function typewriteExample() {
if (reversed) {
setTimeout(typewriteExample, 100 - getRandomInt(25));
if (letterId-- > 0) {
searchBox.placeholder = searchBox.placeholder.slice(0, -1);
return;
}
reversed = false;
if (++exampleId >= examples.length) {
exampleId = 0;
}
} else {
setTimeout(typewriteExample, 150 + (getRandomInt(150) - 75));
if (letterId < examples[exampleId].length) {
searchBox.placeholder += examples[exampleId].charAt(letterId++);
return;
}
reversed = true;
}
}
if (examples.length > 0) {
typewriteExample();
}
</script>
</body>
</html>
Интерфейс можно запустить и проверить так:
$ flask run
После запуска появится локальная ссылка, которую можно открыть в браузере.
Что можно доработать
Рекомендатор кино работает: находить новые фильмы стало проще. Но пока это только MVP.
Для полноты можно:
Сделать постеры для фильмов. Собаки красивые, но хочется релевантности.
Сортировать актёров по рейтингу, выделить главные роли, чтобы кодировать их как категориальные признаки.
Добавить больше полей в таблицы базы данных, чтобы в листинге рекомендаций выводить больше информации о фильмах.
Сделать fuzzy search по названию.
Сделать поиск по актёрам, жанру, режиссёру, сценаристам.
Сделать фасеты для страницы результатов, чтобы фильтровать выдачу.
Автор статьи: Дмитрий Сидоров
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS
mrCOTOHA
DIW