!pip install implicit --quiet
!pip install catboost --quiet
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 8.9/8.9 MB 58.5 MB/s eta 0:00:00
import datetime
import numpy as np
import pandas as pd
from tqdm.auto import tqdm
import matplotlib.pyplot as plt
from sklearn.utils import shuffle
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score
import scipy.sparse as sparse
from catboost import CatBoostClassifier
import implicit
from implicit.bpr import BayesianPersonalizedRanking as BPR
import warnings; warnings.filterwarnings('ignore')
def recall(df: pd.DataFrame, pred_col='preds', true_col='item_id', k=30) -> float:
recall_values = []
for _, row in df.iterrows():
num_relevant = len(set(row[true_col]) & set(row[pred_col][:k]))
num_true = len(row[true_col])
recall_values.append(num_relevant / num_true)
return np.mean(recall_values)
def precision(df: pd.DataFrame, pred_col='preds', true_col='item_id', k=30) -> float:
precision_values = []
for _, row in df.iterrows():
num_relevant = len(set(row[true_col]) & set(row[pred_col][:k]))
num_true = min(k, len(row[true_col]))
precision_values.append(num_relevant / num_true)
return np.mean(precision_values)
def mrr(df: pd.DataFrame, pred_col='preds', true_col='item_id', k=30) -> float:
mrr_values = []
for _, row in df.iterrows():
intersection = set(row[true_col]) & set(row[pred_col][:k])
user_mrr = 0
if len(intersection) > 0:
for item in intersection:
user_mrr = max(user_mrr, 1 / (row[pred_col].index(item) + 1))
mrr_values.append(user_mrr)
return np.mean(mrr_values)
Гибридные методы рекомендации¶
1 | Задание¶
Цель:¶
- В этом задании вы продолжите работу с двухуровневой моделью, попробуете добавлять (1) новые признаки, (2) а также модели первого уровня, (3) подберете гиперпараметры.
- В результате доработок качество модели, рассмотренной на занятии, должно улучшиться.
Пошаговая инструкция:¶
Продолжим работу с данными онлайн-кинотеатра KION: https://github.com/irsafilo/KION_DATASET/tree/main
Задача - улучшить качество двухуровневой модели, рассмотренной на занятии, минимум на 10% по метрике precision@20. Для этого предлагается проделать хотя бы два из трех шагов:
(модификация кандидатов) добавить одну или несколько моделей первого уровня (можно брать модели из библиотеки implicit или других библиотек); на основе предсказаний этих моделей отобрать объекты-кандидаты для модели второго уровня;
фича-инжиниринг: поработать с имеющимися характеристиками пользователей и объектов и/или сгенерировать новые фичи на основе популярности объектов/активности пользователей; попробовать учесть временную составляющую, например, считать популярность за разные временные промежутки;
подобрать лучшее train-val-test разбиение; подобрать гиперпараметры моделей первого и второго уровней.
Оценить качество модели по метрикам precision@20, recall@20, mrr@20
2 | Чтение данных¶
readData
: класс для чтение и хранение исходных данных
class readData:
def __init__(self):
self.read_data()
def read_data(self):
self.interactions = pd.read_csv("/kaggle/input/kion-dataset/interactions.csv")
self.items = pd.read_csv("/kaggle/input/kion-dataset/items.csv")
self.users = pd.read_csv("/kaggle/input/kion-dataset/users.csv")
# convert the column [last_watch_dt] into datetime
self.interactions['last_watch_dt'] = pd.to_datetime(self.interactions['last_watch_dt']).map(lambda x: x.date())
print(f"Уникальных юзеров в interactions: {self.interactions['user_id'].nunique()}")
print(f"Уникальных айтемов в interactions: {self.interactions['item_id'].nunique()}")
def show_interactions(self):
return self.interactions.head()
def show_items(self):
return self.items.head()
def show_user(self):
return self.users.head()
3 | Предобработка данных¶
dataPreprocess
класс для предобработки данных
- Для текущего ноутбука мы не меняем выборку
- класс инициилизирует кюродительский класс где хронятся данные
class dataPreprocess(readData):
def __init__(self):
super().__init__()
self.filter_data()
self.tts()
def filter_data(self):
interactions = self.interactions
interactions = interactions[interactions['total_dur'] >= 300]
user_interactions_count = interactions.groupby('user_id')[['item_id']].count().reset_index()
filtered_users = user_interactions_count[user_interactions_count['item_id'] >= 10][['user_id']]
interactions = filtered_users.merge(interactions, how='left')
self.interactions = interactions
def tts(self):
interactions = self.interactions
max_date = interactions['last_watch_dt'].max()
min_date = interactions['last_watch_dt'].min()
print(f"min дата в interactions: {min_date}")
print(f"max дата в interactions: {max_date}")
# global test dataset starting time (7 days)
test_threshold = max_date - pd.Timedelta(days=7)
# validation dataset starting time (2 months)
val_threshold = test_threshold - pd.Timedelta(days=60)
self.test = interactions[(interactions['last_watch_dt'] >= test_threshold)]
train_val = interactions[(interactions['last_watch_dt'] < test_threshold)]
self.val = train_val[(train_val['last_watch_dt'] >= val_threshold)]
self.train = train_val[(train_val['last_watch_dt'] < val_threshold)]
print('Data split into subsets!')
print(f"train: {self.train.shape}")
print(f"val: {self.val.shape}")
print(f"test: {self.test.shape}")
def show_subset(self,subset='train'):
if(subset == 'train'):
return self.train
elif(subset == 'val'):
return self.val
elif(subset == 'test'):
return self.test
kion = dataPreprocess()
kion.show_subset('train')
Уникальных юзеров в interactions: 962179 Уникальных айтемов в interactions: 15706 min дата в interactions: 2021-03-13 max дата в interactions: 2021-08-22 Data split into subsets! train: (894491, 5) val: (1253718, 5) test: (173795, 5)
user_id | item_id | last_watch_dt | total_dur | watched_pct | |
---|---|---|---|---|---|
0 | 2 | 7571 | 2021-05-20 | 6151 | 100.0 |
1 | 2 | 3541 | 2021-06-04 | 4320 | 83.0 |
2 | 2 | 15266 | 2021-06-01 | 5422 | 100.0 |
4 | 2 | 12841 | 2021-06-09 | 8152 | 100.0 |
6 | 2 | 4475 | 2021-05-30 | 7029 | 100.0 |
... | ... | ... | ... | ... | ... |
2321985 | 1097516 | 758 | 2021-04-20 | 796 | 13.0 |
2321986 | 1097516 | 14470 | 2021-04-24 | 553 | 7.0 |
2321989 | 1097516 | 1331 | 2021-05-17 | 2563 | 39.0 |
2321990 | 1097516 | 8454 | 2021-04-22 | 6169 | 100.0 |
2321991 | 1097516 | 15421 | 2021-04-23 | 6709 | 100.0 |
894491 rows × 5 columns
4 | Выбор кандидатов¶
genCandidate
: Используем для генерации позитивных кадидатов которые используем для фич модели второго уровня
class genCandidate:
def __init__(self,
train:pd.DataFrame,
val:pd.DataFrame,
test:pd.DataFrame):
self.train = train
self.val = val
self.test = test
def createRatingMatrix(self):
# train model on [train]
users_id = list(np.sort(self.train.user_id.unique()))
items_train = list(self.train.item_id.unique())
ratings_train = list(self.train.watched_pct)
# Конвертируем ids
self.rows_train = self.train.user_id.astype('category').cat.codes
self.cols_train = self.train.item_id.astype('category').cat.codes
# create sparse rating matrix (watched percentage [watched_pct])
self.rating_matrix = sparse.csr_matrix((ratings_train, (self.rows_train,
self.cols_train)),
shape=(len(users_id), len(items_train)))
def decompose(self):
# Модель кандидаты
algo = BPR(factors=50,
regularization=0.01,
iterations=50,
use_gpu=False)
algo.fit((self.rating_matrix).astype('double'))
# user and item matrix
self.user_vecs = algo.user_factors
self.item_vecs = algo.item_factors
# BPR implicit prediction
def predict(self,k=10):
"""
Helper function for matrix factorisation prediction
"""
user_vecs = self.user_vecs
item_vecs = self.item_vecs
id2user = dict(zip(self.rows_train, self.train.user_id))
id2item = dict(zip(self.cols_train, self.train.item_id))
scores = user_vecs.dot(item_vecs.T)
ind_part = np.argpartition(scores, -k + 1)[:, -k:].copy()
scores_not_sorted = np.take_along_axis(scores, ind_part, axis=1)
ind_sorted = np.argsort(scores_not_sorted, axis=1)
indices = np.take_along_axis(ind_part, ind_sorted, axis=1)
indices = np.flip(indices, 1)
preds = pd.DataFrame({
'user_id': range(user_vecs.shape[0]),
'preds': indices.tolist(),
})
preds['user_id'] = preds['user_id'].map(id2user)
preds['preds'] = preds['preds'].map(lambda inds: [id2item[i] for i in inds])
# prediction scores for train
self.preds = preds
def create_candidates(self,k=10,subset='valid'):
# films watched in [val] dataset
if(subset == 'valid'):
self.user_history = self.val.groupby('user_id')[['item_id']].agg(lambda x: list(x))
elif(subset == 'test'):
self.user_history = self.test.groupby('user_id')[['item_id']].agg(lambda x: list(x))
self.predict(k=k)
pred_bpr = self.user_history.merge(self.preds, how='left', on='user_id')
pred_bpr = pred_bpr.dropna(subset=['preds'])
print('recall@k ',round(recall(pred_bpr),2))
print('precision@k ',round(precision(pred_bpr),2))
print('mrr@k ',round(mrr(pred_bpr),2))
candidates = pred_bpr[['user_id', 'preds']]
candidates = candidates.explode('preds').rename(columns={'preds': 'item_id'})
candidates['rank'] = candidates.groupby('user_id').cumcount() + 1
self.candidates = candidates
cand = genCandidate(train=kion.train,
val=kion.val,
test=kion.test)
cand.createRatingMatrix()
cand.decompose()
# матрица рейтингов которые получает от
# декомпозиции train
cand.rating_matrix
<72383x11373 sparse matrix of type '<class 'numpy.float64'>' with 894491 stored elements in Compressed Sparse Row format>
# вектора пользователей для bpr (factors=50)
cand.user_vecs
array([[ 0.12379745, -0.15161823, 0.21322107, ..., 0.30594668, 0.03928256, 1. ], [-0.10700278, 0.20008633, -0.08893105, ..., -0.14906016, -0.05412726, 1. ], [-0.06540288, 0.1305269 , -0.03879986, ..., 0.10091428, -0.11864812, 1. ], ..., [-0.05447918, 0.1625216 , -0.00793439, ..., 0.17104897, 0.5004299 , 1. ], [-0.07643098, -0.09024178, 0.1201185 , ..., 0.29327735, -0.375865 , 1. ], [-0.08880109, 0.05362898, -0.01743633, ..., 0.267542 , 0.14116102, 1. ]], dtype=float32)
- Выбираем k=100 для формирования быборки второго уровня
- Создаем кандидатов для пользователей, это фильмы которые модель считает что он посмотрел, мы это проверим и разделим выборка на позитивные и негативные подвыборки
# Формируем кандидатов для модели 2го уровня (val)
cand.create_candidates(k=100)
recall@k 0.12 precision@k 0.12 mrr@k 0.14
# Обучаем модель 2го уровня (классификатор)
# Цель классификатора : пресказывать комбинации которые смотрел клиент
class catClassifier:
def __init__(self,val,candidates,users,items):
self.val = val
self.candidates = candidates
self.users = users
self.items = items
def gen_data(self):
# positive candidates
pos = self.candidates.merge(self.val,
on=['user_id', 'item_id'],
how='inner')
pos['target'] = 1
self.pos = pos
print('number of positive samples',pos.shape)
# предсказанные фильмы они не смотрели в валидационной выборке
neg = self.candidates.set_index(['user_id', 'item_id'])\
.join(self.val.set_index(['user_id', 'item_id']))
neg = neg[neg['watched_pct'].isnull()].reset_index()
# print(neg.shape)
neg = neg.sample(frac=0.07)
print('number of negative samples',neg.shape)
neg['target'] = 0
self.neg = neg
def create_subsets(self):
# divide the users into 3 subgroups
ctb_train_users, ctb_test_users = train_test_split(self.val['user_id'].unique(),
random_state=1,
test_size=0.2)
ctb_train_users, ctb_eval_users = train_test_split(ctb_train_users,
random_state=1,
test_size=0.1)
print('number of users in ctb train',ctb_train_users)
print('number of users in ctb eval',ctb_eval_users)
print('number of users in ctb test',ctb_test_users)
# Все базовые колонки
select_col = ['user_id', 'item_id', 'rank', 'target']
# train (basic)
ctb_train = shuffle(
pd.concat([
self.pos[self.pos['user_id'].isin(ctb_train_users)],
self.neg[self.neg['user_id'].isin(ctb_train_users)]
])[select_col]
)
# test (basic)
ctb_test = shuffle(
pd.concat([
self.pos[self.pos['user_id'].isin(ctb_test_users)],
self.neg[self.neg['user_id'].isin(ctb_test_users)]
])[select_col]
)
# evaluation (basic)
ctb_eval = shuffle(
pd.concat([
self.pos[self.pos['user_id'].isin(ctb_eval_users)],
self.neg[self.neg['user_id'].isin(ctb_eval_users)]
])[select_col]
)
'''
1. Additional Features
'''
self.user_col = ['user_id', 'age', 'income', 'sex', 'kids_flg']
# self.item_col = ['item_id', 'content_type', 'countries', 'for_kids', 'age_rating', 'studios']
self.item_col = ['item_id','content_type','countries','for_kids','age_rating','studios','release_year','directors']
# train
train_feat = (ctb_train
.merge(self.users[self.user_col], on=['user_id'], how='left')
.merge(self.items[self.item_col], on=['item_id'], how='left'))
# evaluation
eval_feat = (ctb_eval
.merge(self.users[self.user_col], on=['user_id'], how='left')
.merge(self.items[self.item_col], on=['item_id'], how='left'))
# drop pointless columns and separate target
self.drop_col = ['user_id', 'item_id']
self.target_col = ['target']
# we will define the categorical columns in catboost
self.cat_col = ['age', 'income', 'sex', 'content_type', 'countries', 'studios','directors']
self.X_train, self.y_train = train_feat.drop(self.drop_col + self.target_col, axis=1), train_feat[self.target_col]
self.X_val, self.y_val = eval_feat.drop(self.drop_col + self.target_col, axis=1), eval_feat[self.target_col]
# fillna for catboost with the most frequent value
self.X_train = self.X_train.fillna(self.X_train.mode().iloc[0])
self.X_val = self.X_val.fillna(self.X_train.mode().iloc[0])
'''
2. Prepare Test Set
'''
test_feat = (ctb_test
.merge(self.users[self.user_col], on=['user_id'], how='left')
.merge(self.items[self.item_col], on=['item_id'], how='left'))
# fillna for catboost with the most frequent value
test_feat = test_feat.fillna(self.X_train.mode().iloc[0])
self.X_test, self.y_test = test_feat.drop(self.drop_col + self.target_col, axis=1), test_feat['target']
print(f'X_train: {self.X_train.shape}')
print(f'X_val: {self.X_val.shape}')
print(f'X_test: {self.X_test.shape}')
def train(self):
# model hyperparameters
est_params = {
'subsample': 0.9,
'max_depth': 5,
'n_estimators': 5000,
'learning_rate': 0.01,
'thread_count': 20,
'random_state': 42,
'verbose': 200,
}
ctb_model = CatBoostClassifier(**est_params)
import warnings; warnings.filterwarnings('ignore')
ctb_model.fit(self.X_train,
self.y_train,
eval_set=(self.X_val, self.y_val),
early_stopping_rounds=100,
cat_features=self.cat_col)
self.model = ctb_model
def predict_test(self):
y_pred = self.model.predict_proba(self.X_test)
f"ROC AUC score = {roc_auc_score(self.y_test, y_pred[:, 1]):.2f}"
# cat = catClassifier(kion.val, # kion.val : глобальная валидационная выборка взаимодействии
# cand.candidates, # cand.candidates : Кандидаты из модели 1го уровня
# kion.users, # kion.users : признаки пользователя
# kion.items) # kion.items : признаки предметов
# cat.gen_data() # generate positive and negative samples from candidates based on validation set
# cat.create_subsets() # Формируем X_train,X_test,X_val для модели 2го уровня
# Обучаем модель 2го уровня
# cat.train()
2) Оптимизация Гиперапараметров¶
Мы можем использовать optuna
для подбора гиперпараметров парамерты которые будем оптимизировать находятся в param
и имеют значение trial.suggest_
subsample
depth
n_estimators
learning_rate
import optuna
optuna.logging.set_verbosity(optuna.logging.WARNING)
class catClassifierOptuna(catClassifier):
def __init__(self,val,candidates,users,items):
self.val = val
self.candidates = candidates
self.users = users
self.items = items
def __init__(self,val,candidates,users,items):
super().__init__(val,candidates,users,items)
# обучение модели
def train(self):
study = optuna.create_study(direction="maximize")
study.optimize(self.objective, n_trials=50, timeout=600,show_progress_bar=True)
# обучаем модель используя параметры которые дали самый высокий ROC-AUC
self.model = CatBoostClassifier(**study.best_params,silent=True)
import warnings; warnings.filterwarnings('ignore')
self.model.fit(self.X_train,
self.y_train,
eval_set=(self.X_val, self.y_val),
early_stopping_rounds=100,
cat_features=self.cat_col)
self.model = self.model
# objective function ; что мы оптимизируем -> ROC AUC на тестовой подвыборке
def objective(self,trial):
param = {
'subsample': trial.suggest_float('subsample',0.5,0.95),
"depth": trial.suggest_int("depth", 1, 15),
'n_estimators': trial.suggest_int('n_estimators',5000,7000),
'learning_rate': trial.suggest_float('learning_rate',0.001,0.1),
'thread_count': 20,
'random_state': 42
}
model = CatBoostClassifier(**param,silent=True)
model.fit(self.X_train,
self.y_train,
eval_set=(self.X_val, self.y_val),
early_stopping_rounds=100,
cat_features=self.cat_col)
y_pred = model.predict_proba(self.X_test)
return roc_auc_score(self.y_test, y_pred[:, 1])
cat = catClassifierOptuna(kion.val, # kion.val : глобальная валидационная выборка взаимодействии
cand.candidates, # cand.candidates : Кандидаты из модели 1го уровня
kion.users, # kion.users : признаки пользователя
kion.items) # kion.items : признаки предметов
cat.gen_data() # generate positive and negative samples from candidates based on validation set
cat.create_subsets() # Формируем X_train,X_test,X_val для модели 2го уровня
number of positive samples (121011, 7) number of negative samples (430233, 6) number of users in ctb train [267415 29717 913817 ... 39932 202551 485139] number of users in ctb eval [ 751973 836142 740671 ... 1276 1012693 80378] number of users in ctb test [ 902000 1004154 188727 ... 636142 56244 933157] X_train: (398276, 12) X_val: (43384, 12) X_test: (109584, 12)
# обучаем модель используя optuna оптимизацию гиперпараметров
cat.train()
6 | Формирования рекомендации¶
Мы используем выборки train
и val
для того чтобы обучить модели которые будут предсказывать рекоммендации на test
- формируем кандидатов используя 100 кандидатов товаров для каждого пользователя
Готовим фичи тестовой глобальной выборки для модели 2го уровня
cat.user_col
,cat.item_col
: обязательно используем те же фичи которые использовали для модели 2го уровня
# модель 2го уровня
cat.model
<catboost.core.CatBoostClassifier at 0x7a7331938460>
# Формируем кандидатов для тестовой выборки
cand.create_candidates(k=100,subset='test')
recall@k 0.06 precision@k 0.06 mrr@k 0.03
# все кандидаты для тестовой выборки
pred_bpr_ctb = cand.candidates.copy()
# фичи для теста
score_feat = (pred_bpr_ctb
.merge(kion.users[cat.user_col], on=['user_id'], how='left')
.merge(kion.items[cat.item_col], on=['item_id'], how='left'))
# fillna for catboost with the most frequent value
score_feat = score_feat.fillna(cat.X_train.mode().iloc[0])
score_feat.head()
user_id | item_id | rank | age | income | sex | kids_flg | content_type | countries | for_kids | age_rating | studios | release_year | directors | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 21 | 849 | 1 | age_45_54 | income_20_40 | Ж | 0.0 | film | США | 0.0 | 18.0 | HBO | 2018.0 | Кен Кушнер |
1 | 21 | 1053 | 2 | age_45_54 | income_20_40 | Ж | 0.0 | film | США | 0.0 | 18.0 | HBO | 2020.0 | Кларк Дьюк |
2 | 21 | 24 | 3 | age_45_54 | income_20_40 | Ж | 0.0 | series | Германия | 0.0 | 16.0 | HBO | 2020.0 | Флориан Галленбергер |
3 | 21 | 826 | 4 | age_45_54 | income_20_40 | Ж | 0.0 | film | Великобритания | 0.0 | 16.0 | HBO | 2020.0 | Джесси Кинонес |
4 | 21 | 12975 | 5 | age_45_54 | income_20_40 | Ж | 0.0 | film | США | 0.0 | 18.0 | HBO | 2019.0 | Майлз Джорис-Пейрафит |
# гиперпараметры модели
cat.model.get_params()
{'learning_rate': 0.02442520558033121, 'depth': 10, 'silent': True, 'subsample': 0.7247704767962999, 'n_estimators': 5613}
# prediction and sort by predict proba weak values
ctb_prediction = cat.model.predict_proba(score_feat.drop(cat.drop_col, axis=1, errors='ignore'))
pred_bpr_ctb['ctb_pred'] = ctb_prediction[:, 1] # prob for positive class
pred_bpr_ctb = pred_bpr_ctb.sort_values(
by=['user_id', 'ctb_pred'],
ascending=[True, False])
pred_bpr_ctb['rank_ctb'] = pred_bpr_ctb.groupby('user_id').cumcount() + 1
pred_bpr_ctb.head()
user_id | item_id | rank | ctb_pred | rank_ctb | |
---|---|---|---|---|---|
1 | 21 | 14703 | 62 | 0.493133 | 1 |
1 | 21 | 8636 | 75 | 0.429543 | 2 |
1 | 21 | 1132 | 64 | 0.422380 | 3 |
1 | 21 | 11661 | 36 | 0.419328 | 4 |
1 | 21 | 12659 | 10 | 0.346157 | 5 |
true_items = kion.test.groupby('user_id').agg(lambda x: list(x))[['item_id']].reset_index()
pred_items = pred_bpr_ctb.groupby('user_id').agg(lambda x: list(x))[['item_id']].reset_index().rename(columns={'item_id': 'preds'})
true_pred_items = true_items.merge(pred_items, how='left')
true_pred_items = true_pred_items.dropna(subset=['preds'])
# Параметры на тестовой выборке
print('recall@k',round(recall(true_pred_items, k=20),3))
print('precision@k',round(precision(true_pred_items, k=20),3))
print('mrr@k',round(mrr(true_pred_items, k=20),3))
recall@k 0.072 precision@k 0.072 mrr@k 0.051
Шаги для улучшения модели¶
Выбор кандидатов
- Изночально использовалось 30 кандидатов для каждого пользователя, качесто обоих моделей увеличивалось при увеличении этого параметр, в этом ноутбуке использовалось 100 кандидатов (positive sample).
- Пробовалось несколько вариантов подбора гиперпараметра регурелизации, в исходном ноутабуке использовалось 0.01, при увеличении этого параметра качество модели стало существено хуже. Аналогично если он был сличком маленький, качество модели тоже падало.
- Так же были попытки улучшить качество модели увеличив
factors
от 50 до 75, но это не повлияло на итоговую метрику
Фичи инжиниринг
В исходных данных не все признаки использовались, для улучшения моделей, признаки release_year
,directors
были добавлены для модели 2го уровня что улучшило качество модели
- Прирост от этого добавления этих фич был в районе 0.01
Оптимизация Гиперапараметров модели 2го уровня
Мы можем использовать optuna
для подбора гиперпараметров парамерты которые будем оптимизировать находятся в param
и имеют значение trial.suggest_
subsample
depth
n_estimators
learning_rate
Прирост метрпики от этой оптимизации так же составил примерно 0.01
До и после метрики
metric| исходная | изменение | -: | - | - | | recall | recall@k - | recall@k 0.072 | precision | было 0.0468 | precision@k 0.072 | mrr | mrr@k - | mrr@k 0.051 |