Фундаментальное исследование трансформеров в персонализированных рекомендациях фильмов
Содержание
В рекомендательных системах создание персональных рекомендаций всегда было увлекательной задачей. Использование преобразователей для решения этой задачи открывает новые возможности для улучшения систем персонализированных рекомендаций. В этом сообщении блога мы возьмем основные принципы преобразователя и применим их для создания персонализированных рекомендаций с использованием идентификаторов пользователей и последовательности фильмов из набора данных 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, чтобы обучить свою собственную модель. Я настоятельно рекомендую просмотреть ссылки, которые послужили основой для моей реализации.
Рекомендации
- https://keras.io/examples/structured_data/movielens_recommendations_transformers/
- https://pytorch.org/tutorials/beginner/transformer_tutorial.html
- https://scikit-learn.org/stable/modules/generated/sklearn.metrics.ndcg_score.html
Если вы хотите увидеть больше статей, нажмите ссылку, чтобы увидеть другие наши статьи.
Если вам нравится то, что мы делаем, нажмите ссылку, чтобы увидеть возможности.