Фундаментальное исследование трансформеров в персонализированных рекомендациях фильмов

Содержание

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

1. Предварительная обработка данных

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

1.1 Библиотеки

Сначала мы начнем с импорта необходимых библиотек. Эти библиотеки необходимы для загрузки нашего набора данных, создания словарей и построения модели на основе Transformer.

import time
import math
import os
from tempfile import TemporaryDirectory
from typing import Tuple

import torch
from torch import nn, Tensor
from torch.nn import TransformerEncoder, TransformerEncoderLayer
from torch.utils.data import dataset
from torchtext.vocab import vocab
from torch.utils.data import DataLoader, Dataset
from torch.nn.utils.rnn import pad_sequence

from collections import Counter

from zipfile import ZipFile
from urllib.request import urlretrieve

import pandas as pd
import numpy as np

1.2 Загрузка набора данных

Теперь мы загрузим наш набор данных, чтобы сгенерировать наши последовательности и словари. Затем значения user_id и movie_id обрабатываются для исправления их типов данных.

# Downloading dataset
urlretrieve("http://files.grouplens.org/datasets/movielens/ml-1m.zip", "movielens.zip")
ZipFile("movielens.zip", "r").extractall()

# Loading dataset
users = pd.read_csv(
    "ml-1m/users.dat",
    sep="::",
    names=["user_id", "sex", "age_group", "occupation", "zip_code"],
)

ratings = pd.read_csv(
    "ml-1m/ratings.dat",
    sep="::",
    names=["user_id", "movie_id", "rating", "unix_timestamp"],
)
movies = pd.read_csv(
    "ml-1m/movies.dat", sep="::", names=["movie_id", "title", "genres"], encoding='latin-1'
)
# Preventing ids to be written as integer or float data type
users["user_id"] = users["user_id"].apply(lambda x: f"user_{x}")
movies["movie_id"] = movies["movie_id"].apply(lambda x: f"movie_{x}")
ratings["movie_id"] = ratings["movie_id"].apply(lambda x: f"movie_{x}")
ratings["user_id"] = ratings["user_id"].apply(lambda x: f"user_{x}")

1.3 Создание словаря

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

# Genarting a list of unique movie ids
movie_ids = movies.movie_id.unique()

# Counter is used to feed movies to movive_vocab
movie_counter = Counter(movie_ids)

# Genarting vocabulary
movie_vocab = vocab(movie_counter, specials=['<unk>'])

# For indexing input ids
movie_vocab_stoi = movie_vocab.get_stoi()

# Movie to title mapping dictionary
movie_title_dict = dict(zip(movies.movie_id, movies.title))

# Similarly generating a vocabulary for user ids
user_ids = users.user_id.unique()
user_counter = Counter(user_ids)
user_vocab = vocab(user_counter, specials=['<unk>'])
user_vocab_stoi = user_vocab.get_stoi()

1.4 Генерация последовательностей

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

# Group ratings by user_id in order of increasing unix_timestamp.
ratings_group = ratings.sort_values(by=["unix_timestamp"]).groupby("user_id")

ratings_data = pd.DataFrame(
    data={
        "user_id": list(ratings_group.groups.keys()),
        "movie_ids": list(ratings_group.movie_id.apply(list)),
        "timestamps": list(ratings_group.unix_timestamp.apply(list)),
    }
)

# Sequence length and window slide size
sequence_length = 4
step_size = 2

# Creating sequences from lists with sliding window
def create_sequences(values, window_size, step_size):
    sequences = []
    start_index = 0
    while True:
        if start_index >= len(values):
            break
        end_index = start_index + window_size
        seq = values[start_index:end_index]
        if len(seq) < window_size:
            seq = values[-window_size:]
            if len(seq) == window_size:
                sequences.append(seq)
            break
        sequences.append(seq)
        start_index += step_size
    return sequences

ratings_data.movie_ids = ratings_data.movie_ids.apply(
    lambda ids: create_sequences(ids, sequence_length, step_size)
)

del ratings_data["timestamps"]

# Sub-sequences are exploded.
# Since there might be more than one sequence for each user.
ratings_data_transformed = ratings_data[["user_id", "movie_ids"]].explode(
    "movie_ids", ignore_index=True
)

ratings_data_transformed.rename(
    columns={"movie_ids": "sequence_movie_ids"},
    inplace=True,
)

Сплит-тест 1.5

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

# Random indexing
random_selection = np.random.rand(len(ratings_data_transformed.index)) <= 0.85

# Split train data
df_train_data = ratings_data_transformed[random_selection]
train_data_raw = df_train_data[["user_id", "sequence_movie_ids"]].values

# Split test data
df_test_data = ratings_data_transformed[~random_selection]
test_data_raw = df_test_data[["user_id", "sequence_movie_ids"]].values

DataLoader используется для обучения и оценки в качестве заключительного этапа предварительной обработки.

# Pytorch Dataset for user interactions
class MovieSeqDataset(Dataset):
    # Initialize dataset
    def __init__(self, data, movie_vocab_stoi, user_vocab_stoi):
        self.data = data
        self.movie_vocab_stoi = movie_vocab_stoi
        self.user_vocab_stoi = user_vocab_stoi


    def __len__(self):
        return len(self.data)

    # Fetch data from the dataset
    def __getitem__(self, idx):
        user, movie_sequence = self.data[idx]
        # Directly index into the vocabularies
        movie_data = [self.movie_vocab_stoi[item] for item in movie_sequence]
        user_data = self.user_vocab_stoi[user]
        return torch.tensor(movie_data), torch.tensor(user_data)


# Collate function and padding
def collate_batch(batch):
    movie_list = [item[0] for item in batch]
    user_list = [item[1] for item in batch]
    return pad_sequence(movie_list, padding_value=movie_vocab_stoi['<unk>'], batch_first=True), torch.stack(user_list)


BATCH_SIZE = 256
# Create instances of your Dataset for each set
train_dataset = MovieSeqDataset(train_data_raw, movie_vocab_stoi, user_vocab_stoi)
val_dataset = MovieSeqDataset(test_data_raw, user_vocab_stoi, user_vocab_stoi)
# Create DataLoaders
train_iter = DataLoader(train_dataset, batch_size=BATCH_SIZE,
                        shuffle=True, collate_fn=collate_batch)
val_iter = DataLoader(val_dataset, batch_size=BATCH_SIZE,
                      shuffle=False, collate_fn=collate_batch)

2. Определение модели

В этом разделе мы определим и инициализируем нашу модель. Затем модель будет обучена с нашим ранее сгенерированным набором данных.

2.1 Позиционный кодировщик

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

class PositionalEncoding(nn.Module):

    def __init__(self, d_model: int, dropout: float = 0.1, max_len: int = 5000):
        super().__init__()
        self.dropout = nn.Dropout(p=dropout)

        position = torch.arange(max_len).unsqueeze(1)

        # `div_term` is used in the calculation of the sinusoidal values.
        div_term = torch.exp(torch.arange(0, d_model, 2) * (-math.log(10000.0) / d_model))
        
        # Initializing positional encoding matrix with zeros.
        pe = torch.zeros(max_len, 1, d_model)

        # Calculating the positional encodings.
        pe[:, 0, 0::2] = torch.sin(position * div_term)
        pe[:, 0, 1::2] = torch.cos(position * div_term)
        self.register_buffer('pe', pe)

    def forward(self, x: Tensor) -> Tensor:
        """
        Arguments:
            x: Tensor, shape ``[seq_len, batch_size, embedding_dim]``
        """
        x = x + self.pe[:x.size(0)]
        return self.dropout(x)

2.2 Модель-трансформер

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

class TransformerModel(nn.Module):
    def __init__(self, ntoken: int, nuser: int, d_model: int, nhead: int, d_hid: int,
                 nlayers: int, dropout: float = 0.5):
        super().__init__()
        self.model_type = 'Transformer'
        # positional encoder
        self.pos_encoder = PositionalEncoding(d_model, dropout)

        # Multihead attention mechanism.
        encoder_layers = TransformerEncoderLayer(d_model, nhead, d_hid, dropout)
        self.transformer_encoder = TransformerEncoder(encoder_layers, nlayers)

        # Embedding layers
        self.movie_embedding = nn.Embedding(ntoken, d_model)
        self.user_embedding = nn.Embedding(nuser, d_model)

        # Defining the size of the input to the model.
        self.d_model = d_model

        # Linear layer to map the output tomovie vocabulary.
        self.linear = nn.Linear(2*d_model, ntoken)

        self.init_weights()

    def init_weights(self) -> None:
        # Initializing the weights of the embedding and linear layers.
        initrange = 0.1
        self.movie_embedding.weight.data.uniform_(-initrange, initrange)
        self.user_embedding.weight.data.uniform_(-initrange, initrange)
        self.linear.bias.data.zero_()
        self.linear.weight.data.uniform_(-initrange, initrange)

    def forward(self, src: Tensor, user: Tensor, src_mask: Tensor = None) -> Tensor:
        # Embedding movie ids and userid
        movie_embed = self.movie_embedding(src) * math.sqrt(self.d_model)
        user_embed = self.user_embedding(user) * math.sqrt(self.d_model)

        # positional encoding
        movie_embed = self.pos_encoder(movie_embed)

        # generating output with final layers
        output = self.transformer_encoder(movie_embed, src_mask)

        # Expand user_embed tensor along the sequence length dimension
        user_embed = user_embed.expand(-1, output.size(1), -1)

        # Concatenate user embeddings with transformer output
        output = torch.cat((output, user_embed), dim=-1)

        output = self.linear(output)
        return output

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

ntokens = len(movie_vocab)  # size of vocabulary
nusers = len(user_vocab)
emsize = 128  # embedding dimension
d_hid = 128  # dimension of the feedforward network model
nlayers = 2  # number of ``nn.TransformerEncoderLayer``
nhead = 2  # number of heads in ``nn.MultiheadAttention``
dropout = 0.2  # dropout probability

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = TransformerModel(ntokens, nusers, emsize, nhead, d_hid, nlayers, dropout).to(device)

criterion = nn.CrossEntropyLoss()
lr = 1.0  # learning rate
optimizer = torch.optim.SGD(model.parameters(), lr=lr)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, 1.0, gamma=0.95)

3. Обучение и оценка

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

3.1 Функция поезда

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

def train(model: nn.Module, train_iter, epoch) -> None:
    # Switch to training mode
    model.train()
    total_loss = 0.
    log_interval = 200
    start_time = time.time()

    for i, (movie_data, user_data) in enumerate(train_iter):
        # Load movie sequence and user id
        movie_data, user_data = movie_data.to(device), user_data.to(device)
        user_data = user_data.reshape(-1, 1)

        # Split movie sequence to inputs and targets
        inputs, targets = movie_data[:, :-1], movie_data[:, 1:]
        targets_flat = targets.reshape(-1)

        # Predict movies
        output = model(inputs, user_data)
        output_flat = output.reshape(-1, ntokens)
        
        # Backpropogation process
        loss = criterion(output_flat, targets_flat)
        optimizer.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
        optimizer.step()
        
        total_loss += loss.item()
        # Results
        if i % log_interval == 0 and i > 0:
            lr = scheduler.get_last_lr()[0]
            ms_per_batch = (time.time() - start_time) * 1000 / log_interval
            cur_loss = total_loss / log_interval
            ppl = math.exp(cur_loss)
            print(f'| epoch {epoch:3d} '
                  f'lr {lr:02.2f} | ms/batch {ms_per_batch:5.2f} | '
                  f'loss {cur_loss:5.2f} | ppl {ppl:8.2f}')
            total_loss = 0
            start_time = time.time()

3.2 Функция оценки

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

def evaluate(model: nn.Module, eval_data: Tensor) -> float:
    # Switch the model to evaluation mode.
    # This is necessary for layers like dropout,
    model.eval() 
    total_loss = 0.

    with torch.no_grad():
        for i, (movie_data, user_data) in enumerate(eval_data):
            # Load movie sequence and user id
            movie_data, user_data = movie_data.to(device), user_data.to(device)
            user_data = user_data.reshape(-1, 1)
            # Split movie sequence to inputs and targets
            inputs, targets = movie_data[:, :-1], movie_data[:, 1:]
            targets_flat = targets.reshape(-1)
            # Predict movies
            output = model(inputs, user_data)
            output_flat = output.reshape(-1, ntokens)
            # Calculate loss
            loss = criterion(output_flat, targets_flat)
            total_loss += loss.item()
    return total_loss / (len(eval_data) - 1)

3.3 Цикл обучения и оценки

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

best_val_loss = float('inf')
epochs = 10

with TemporaryDirectory() as tempdir:
    best_model_params_path = os.path.join(tempdir, "best_model_params.pt")

    for epoch in range(1, epochs + 1):
        epoch_start_time = time.time()

        # Training
        train(model)

        # Evaluation
        val_loss = evaluate(model, val_iter)

        # Compute the perplexity of the validation loss
        val_ppl = math.exp(val_loss)
        elapsed = time.time() - epoch_start_time

        # Results
        print('-' * 89)
        print(f'| end of epoch {epoch:3d} | time: {elapsed:5.2f}s | '
            f'valid loss {val_loss:5.2f} | valid ppl {val_ppl:8.2f}')
        print('-' * 89)

        # Save best model
        if val_loss < best_val_loss:
            best_val_loss = val_loss
            torch.save(model.state_dict(), best_model_params_path)

        scheduler.step()
    model.load_state_dict(torch.load(best_model_params_path)) # load best model states

3.4 Создание рекомендаций популярных фильмов в качестве основы

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

def get_popular_movies(df_ratings):
  # Calculate the number of ratings for each movie
  rating_counts = df_ratings['movie_id'].value_counts().reset_index()
  rating_counts.columns = ['movie_id', 'rating_count']

  # Get the most frequently rated movies
  min_ratings_threshold = rating_counts['rating_count'].quantile(0.95)

  # Filter movies based on the minimum number of ratings
  popular_movies = ratings.merge(rating_counts, on='movie_id')
  popular_movies = popular_movies[popular_movies['rating_count'] >= min_ratings_threshold]

  # Calculate the average rating for each movie
  average_ratings = popular_movies.groupby('movie_id')['rating'].mean().reset_index()

  # Get the top 10 rated movies
  top_10_movies = list(average_ratings.sort_values('rating', ascending=False).head(10).movie_id.values)
  return top_10_movies

3.5 Сравнение результатов рекомендаций

Как и в случае с функцией оценки, мы будем повторять наш набор данных для проверки и сохранять результаты рекомендаций в списках, чтобы сравнивать их с популярными фильмами с использованием нормализованной метрики дисконтированного усиления (NDCG).

# Movie id decoder
movie_vocab_itos = movie_vocab.get_itos()

# A placeholders to store results of recommendations
transformer_reco_results = list()
popular_reco_results = list()

# Get top 10 movies
k = 10
# Iterate over the validation data
for i, (movie_data, user_data) in enumerate(val_iter): 
    # Feed the input and get the outputs
    movie_data, user_data = movie_data.to(device), user_data.to(device)
    user_data = user_data.reshape(-1, 1)
    inputs, targets = movie_data[:, :-1], movie_data[:, 1:]
    output = model(inputs, user_data)
    output_flat = output.reshape(-1, ntokens)
    targets_flat = targets.reshape(-1)

    # Reshape the output_flat to get top predictions
    outputs = output_flat.reshape(output_flat.shape[0] // inputs.shape[1],
                                  inputs.shape[1],
                                  output_flat.shape[1])[: , -1, :]
    # k + inputs.shape[1] = 13 movies obtained
    # In order to prevent to recommend already watched movies
    values, indices = outputs.topk(k + inputs.shape[1], dim=-1)

    for sub_sequence, sub_indice_org in zip(movie_data, indices):
        sub_indice_org = sub_indice_org.cpu().detach().numpy()
        sub_sequence = sub_sequence.cpu().detach().numpy()

        # Generate mask array to eliminate already watched movies 
        mask = np.isin(sub_indice_org, sub_sequence[:-1], invert=True)

        # After masking get top k movies
        sub_indice = sub_indice_org[mask][:k]

        # Generate results array
        transformer_reco_result = np.isin(sub_indice, sub_sequence[-1]).astype(int)

        # Decode movie to search in popular movies
        target_movie_decoded = movie_vocab_itos[sub_sequence[-1]]
        popular_reco_result = np.isin(top_10_movies, target_movie_decoded).astype(int)

        transformer_reco_results.append(transformer_reco_result)
        popular_reco_results.append(popular_reco_result)

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

from sklearn.metrics import ndcg_score

# Since we have already sorted our recommendations
# An array that represent our recommendation scores is used.
representative_array = [[i for i in range(k, 0, -1)]] * len(transformer_reco_results)

for k in [3, 5, 10]:
  transformer_result = ndcg_score(transformer_reco_results,
                                  representative_array, k=k)
  popular_result = ndcg_score(popular_reco_results,
                              representative_array, k=k)
  
  print(f"Transformer NDCG result at top {k}: {round(transformer_result, 4)}")
  print(f"Popular recommendation NDCG result at top {k}: {round(popular_result, 4)}\n\n")

Здесь мы увидели, что результаты нашей модели примерно в 10 раз лучше, чем рекомендуемые популярные фильмы по показателю NDCG. Наглядный пример запроса взаимодействия пользователя с рекомендацией фильма приведен ниже.

Пример последовательности пользователей:

Рекомендации по модели трансформатора:

Рекомендации популярных фильмов:

Заключение

В этом сообщении блога мы предприняли попытку использовать модель трансформера, известную своей эффективностью в НЛП, для создания персонализированной системы рекомендаций фильмов. Мы прошли этап от предварительной обработки данных до этапа прогнозирования с использованием набора данных MovieLens. Хотя это отправная точка, и нам предстоит еще многое узнать, мы надеемся, что она прольет свет на то, как модели Transformer можно использовать в различных контекстах, например, в системах рекомендаций.

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

Рекомендации

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

Если вам нравится то, что мы делаем, нажмите ссылку, чтобы увидеть возможности.