31 KiB
%matplotlib inline
%load_ext autoreload
%autoreload 2
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from IPython.display import Markdown, display, HTML
from collections import defaultdict
# Fix the dying kernel problem (only a problem in some installations - you can remove it, if it works without it)
import os
os.environ['KMP_DUPLICATE_LIB_OK'] = 'True'
Load the dataset for recommenders
data_path = os.path.join("data", "hotel_data")
interactions_df = pd.read_csv(os.path.join(data_path, "hotel_data_interactions_df.csv"), index_col=0)
base_item_features = ['term', 'length_of_stay_bucket', 'rate_plan', 'room_segment', 'n_people_bucket', 'weekend_stay']
column_values_dict = {
'term': ['WinterVacation', 'Easter', 'OffSeason', 'HighSeason', 'LowSeason', 'MayLongWeekend', 'NewYear', 'Christmas'],
'length_of_stay_bucket': ['[0-1]', '[2-3]', '[4-7]', '[8-inf]'],
'rate_plan': ['Standard', 'Nonref'],
'room_segment': ['[0-160]', '[160-260]', '[260-360]', '[360-500]', '[500-900]'],
'n_people_bucket': ['[1-1]', '[2-2]', '[3-4]', '[5-inf]'],
'weekend_stay': ['True', 'False']
}
interactions_df.loc[:, 'term'] = pd.Categorical(
interactions_df['term'], categories=column_values_dict['term'])
interactions_df.loc[:, 'length_of_stay_bucket'] = pd.Categorical(
interactions_df['length_of_stay_bucket'], categories=column_values_dict['length_of_stay_bucket'])
interactions_df.loc[:, 'rate_plan'] = pd.Categorical(
interactions_df['rate_plan'], categories=column_values_dict['rate_plan'])
interactions_df.loc[:, 'room_segment'] = pd.Categorical(
interactions_df['room_segment'], categories=column_values_dict['room_segment'])
interactions_df.loc[:, 'n_people_bucket'] = pd.Categorical(
interactions_df['n_people_bucket'], categories=column_values_dict['n_people_bucket'])
interactions_df.loc[:, 'weekend_stay'] = interactions_df['weekend_stay'].astype('str')
interactions_df.loc[:, 'weekend_stay'] = pd.Categorical(
interactions_df['weekend_stay'], categories=column_values_dict['weekend_stay'])
display(HTML(interactions_df.head(15).to_html()))
Define user features based on reservations
The content-based recommenders will be forecasting the probability of interaction between user and item based on user features vector and item features vector:
$$ r_{u, i} = f(user\_features, item\_features) $$Task:
Design numerical user features based on user reservations. Code the following method which for a given interactions DataFrame (it will be used in the fit method of the recommender) returns a DataFrame with user_id and user features as well as a list with names of user features (this will be important to select the right columns for an ML algorithm). Remember to name the columns differently than item features which you will create in the next task. Validate your features on users with several interactions (sample user ids are already given below).
Ideas for user features:
- Find the vector of most popular feature values from all user reservations and encode every feature with one-hot encoding.
- For every reservation feature calculate the probability distribution of its values among all user's reservations.
- For numerical buckets (length_of_stay, room_segment, n_people) you can calculate the average value for every user from their reservations (you will have to map the buckets back to numerical values before averaging them).
Remember that you will have to select the best features (with the highest explanatory power). Using all above features at once would make the number of variables too large for this dataset and would also introduce too much correlations between features.
You can also prepare several version of the prepare_users_df method and test which works best in your recommender.
def prepare_users_df(interactions_df):
# Write your code here
return users_df, user_features
users_df, user_features = prepare_users_df(interactions_df)
print(user_features)
display(HTML(users_df.loc[users_df['user_id'].isin([706, 1736, 7779, 96, 1, 50, 115])].head(15).to_html()))
Prepare numerical item features
Task:
Code the prepare_items_df method which will be used in the recommender fit and recommend methods to map items to numerical features. This method should take the interactions_df DataFrame as input and return a DataFrame containing one record per item_id with item_id column and numerical item feature columns.
You can try turning all item features into on-hot representations. You can use the get_dummies method from pandas. It will return the same columns on any dataset of interactions because of the categorical variables with all possible values have been defined in the second cell in this notebook.
You are welcome to design your own numerical item features.
def prepare_items_df(interactions_df):
# Write your code here
return items_df, item_features
items_df, item_features = prepare_items_df(interactions_df)
print(item_features)
display(HTML(items_df.loc[items_df['item_id'].isin([0, 1, 2, 3, 4, 5, 6])].head(15).to_html()))
Content-based recommender
Task:
Code the content-based recommender. User features should be calculated within the fit method based on available training data and should be saved in the object for later use in the recommend method. Overwrite the users_df variable. Item features should be calculated both in the fit method (from interactions_df) and in the recommend method (from items_df - the items to be evaluated).
In the fit method you have to randomly generate non-existing interactions and add them to the training data for the regressor. You should add the target variable to interactions - equal to 1 for real interactions and equal to 0 for those newly added interactions. Generate several negative interactions per every positive interactions (n_neg_per_pos). Treat the proportion as a tunable parameter of the model.
Remember to keep control over randomness - in the init method add seed as a parameter and use initialize the random seed generator with that seed:
self.seed = seed
self.rng = np.random.RandomState(seed=seed)
Below the base content-based recommender class there are several classes which inherit from the base class and use different ML models:
LinearRegressionCBUIRecommender - based on linear regression,
SVRCBUIRecommender - based on Support Vector Regressor (if you want to test it, sample the data in the fit method, as the training can take many hours on the entire dataset of interactions),
RandomForestCBUIRecommender - based on Random Forest,
XGBoostCBUIRecommender - based on XGBoost.
There is no need to change anything in those inheriting classes, although you can experiment with other tunable parameters of the underlying models.
You are encouraged to experiment with:
- Other numerical user and item features (but always train and evaluate the model on buckets defined in the first notebook).
- Other ML models, e.g. Huber regression, Lasso regression, Ridge regression, LARS regression, Linear SVR, Decision Tree, Naive Bayes, Neural Networks or any model of your choice.
- A different approach where you treat each item as a class, you train directly on categorical features of items and users (you would have to design appropriate categorical features for users) and you fit classifiers (e.g. Decision Tree classifier, Naive Bayes classifier etc.) instead of regressors.
from sklearn.linear_model import LinearRegression
from sklearn.svm import SVR
from sklearn.ensemble import RandomForestRegressor
from sklearn.ensemble import GradientBoostingRegressor
from recommenders.recommender import Recommender
class ContentBasedUserItemRecommender(Recommender):
"""
Linear recommender class based on user and item features.
"""
def __init__(self, seed=6789, n_neg_per_pos=5):
"""
Initialize base recommender params and variables.
"""
self.model = LinearRegression()
self.n_neg_per_pos = n_neg_per_pos
self.recommender_df = pd.DataFrame(columns=['user_id', 'item_id', 'score'])
self.users_df = None
self.user_features = None
self.seed = seed
self.rng = np.random.RandomState(seed=seed)
def fit(self, interactions_df, users_df, items_df):
"""
Training of the recommender.
:param pd.DataFrame interactions_df: DataFrame with recorded interactions between users and items
defined by user_id, item_id and features of the interaction.
:param pd.DataFrame users_df: DataFrame with users and their features defined by user_id and the user feature columns.
:param pd.DataFrame items_df: DataFrame with items and their features defined by item_id and the item feature columns.
"""
interactions_df = interactions_df.copy()
# Prepare users_df and items_df
users_df, user_features = prepare_users_df(interactions_df)
self.users_df = users_df
self.user_features = user_features
items_df, item_features = prepare_items_df(interactions_df)
items_df = items_df.loc[:, ['item_id'] + item_features]
# Generate negative interactions
interactions_df = interactions_df.loc[:, ['user_id', 'item_id']]
interactions_df.loc[:, 'interacted'] = 1
negative_interactions = []
# Write your code here
# Generate tuples (user_id, item_id, 0) for pairs (user_id, item_id) which do not
# appear in the interactions_df and add those tuples to the list negative_interactions.
# Generate self.n_neg_per_pos * len(interactions_df) negative interactions
# (self.n_neg_per_pos per one positive).
# Make sure the code is efficient and runs fast, otherwise you will not be able to properly tune your model.
interactions_df = pd.concat(
[interactions_df, pd.DataFrame(negative_interactions, columns=['user_id', 'item_id', 'interacted'])])
# Get the input data for the model
interactions_df = pd.merge(interactions_df, users_df, on=['user_id'])
interactions_df = pd.merge(interactions_df, items_df, on=['item_id'])
x = interactions_df.loc[:, user_features + item_features].values
y = interactions_df['interacted'].values
self.model.fit(x, y)
def recommend(self, users_df, items_df, n_recommendations=1):
"""
Serving of recommendations. Scores items in items_df for each user in users_df and returns
top n_recommendations for each user.
:param pd.DataFrame users_df: DataFrame with users and their features for which recommendations should be generated.
:param pd.DataFrame items_df: DataFrame with items and their features which should be scored.
:param int n_recommendations: Number of recommendations to be returned for each user.
:return: DataFrame with user_id, item_id and score as columns returning n_recommendations top recommendations
for each user.
:rtype: pd.DataFrame
"""
# Clean previous recommendations (iloc could be used alternatively)
self.recommender_df = self.recommender_df[:0]
# Write your code here
# Prepare users_df and items_df
# For users_df you just need to merge user features from self.users_df to users_df
# (the users for which you generate recommendations)
# For items you have to apply the prepare_items_df method to items_df.
# Score the items
recommendations = pd.DataFrame(columns=['user_id', 'item_id', 'score'])
for ix, user in users_df.iterrows():
# Write your code here
# Create a Carthesian product of users from users_df and items from items_df
# Write your code here
# Use self.model.predict method to calculate scores for all records in the just created DataFrame
# of users and items
# Write your code here
# Obtain item ids with the highest score and save those ids under the chosen_ids variable
# Do not exclude already booked items.
chosen_ids = None
recommendations = []
for item_id in chosen_ids:
recommendations.append(
{
'user_id': user['user_id'],
'item_id': item_id,
'score': scores[item_id]
}
)
user_recommendations = pd.DataFrame(recommendations)
self.recommender_df = pd.concat([self.recommender_df, user_recommendations])
return self.recommender_df
class LinearRegressionCBUIRecommender(ContentBasedUserItemRecommender):
"""
Linear regression recommender class based on user and item features.
"""
def __init__(self, seed=6789, n_neg_per_pos=5, **model_params):
"""
Initialize base recommender params and variables.
"""
super().__init__(seed=seed, n_neg_per_pos=n_neg_per_pos)
self.model = LinearRegression()
class SVRCBUIRecommender(ContentBasedUserItemRecommender):
"""
SVR recommender class based on user and item features.
"""
def __init__(self, seed=6789, n_neg_per_pos=5, **model_params):
"""
Initialize base recommender params and variables.
"""
super().__init__(seed=seed, n_neg_per_pos=n_neg_per_pos)
if 'kernel' in model_params:
self.kernel = model_params['kernel']
else:
self.kernel = 'rbf'
if 'C' in model_params:
self.C = model_params['C']
else:
self.C = 1.0
if 'epsilon' in model_params:
self.epsilon = model_params['epsilon']
else:
self.epsilon = 0.1
self.model = SVR(kernel=self.kernel, C=self.C, epsilon=self.epsilon)
class RandomForestCBUIRecommender(ContentBasedUserItemRecommender):
"""
Random forest recommender class based on user and item features.
"""
def __init__(self, seed=6789, n_neg_per_pos=5, **model_params):
"""
Initialize base recommender params and variables.
"""
super().__init__(seed=seed, n_neg_per_pos=n_neg_per_pos)
if 'n_estimators' in model_params:
self.n_estimators = int(model_params['n_estimators'])
else:
self.n_estimators = 100
if 'max_depth' in model_params:
self.max_depth = int(model_params['max_depth'])
else:
self.max_depth = 30
if 'min_samples_split' in model_params:
self.min_samples_split = int(model_params['min_samples_split'])
else:
self.min_samples_split = 30
self.model = RandomForestRegressor(
n_estimators=self.n_estimators, max_depth=self.max_depth, min_samples_split=self.min_samples_split)
class XGBoostCBUIRecommender(ContentBasedUserItemRecommender):
"""
XGBoost recommender class based on user and item features.
"""
def __init__(self, seed=6789, n_neg_per_pos=5, **model_params):
"""
Initialize base recommender params and variables.
"""
super().__init__(seed=seed, n_neg_per_pos=n_neg_per_pos)
if 'n_estimators' in model_params:
self.n_estimators = int(model_params['n_estimators'])
else:
self.n_estimators = 100
if 'max_depth' in model_params:
self.max_depth = int(model_params['max_depth'])
else:
self.max_depth = 30
if 'min_samples_split' in model_params:
self.min_samples_split = int(model_params['min_samples_split'])
else:
self.min_samples_split = 30
if 'learning_rate' in model_params:
self.learning_rate = model_params['learning_rate']
else:
self.learning_rate = 30
self.model = GradientBoostingRegressor(
n_estimators=self.n_estimators, max_depth=self.max_depth, min_samples_split=self.min_samples_split,
learning_rate=self.learning_rate)
Quick test of the recommender
items_df = interactions_df.loc[:, ['item_id'] + base_item_features].drop_duplicates()
# Fit method
cb_user_item_recommender = RandomForestCBUIRecommender()
cb_user_item_recommender.fit(interactions_df, None, None)
# Recommender method
recommendations = cb_user_item_recommender.recommend(pd.DataFrame([[1], [2], [3], [4], [5]], columns=['user_id']), interactions_df, 10)
recommendations = pd.merge(recommendations, items_df, on='item_id', how='left')
display(HTML(recommendations.to_html()))
Tuning method
from evaluation_and_testing.testing import evaluate_train_test_split_implicit
seed = 6789
from hyperopt import hp, fmin, tpe, Trials
import traceback
def tune_recommender(recommender_class, interactions_df, items_df,
param_space, max_evals=1, show_progressbar=True, seed=6789):
# Split into train_validation and test sets
shuffle = np.arange(len(interactions_df))
rng = np.random.RandomState(seed=seed)
rng.shuffle(shuffle)
shuffle = list(shuffle)
train_test_split = 0.8
split_index = int(len(interactions_df) * train_test_split)
train_validation = interactions_df.iloc[shuffle[:split_index]]
test = interactions_df.iloc[shuffle[split_index:]]
# Tune
def loss(tuned_params):
recommender = recommender_class(seed=seed, **tuned_params)
hr1, hr3, hr5, hr10, ndcg1, ndcg3, ndcg5, ndcg10 = evaluate_train_test_split_implicit(
recommender, train_validation, items_df, seed=seed)
return -hr10
n_tries = 1
succeded = False
try_id = 0
while not succeded and try_id < n_tries:
try:
trials = Trials()
best_param_set = fmin(loss, space=param_space, algo=tpe.suggest,
max_evals=max_evals, show_progressbar=show_progressbar, trials=trials, verbose=True)
succeded = True
except:
traceback.print_exc()
try_id += 1
if not succeded:
return None
# Validate
recommender = recommender_class(seed=seed, **best_param_set)
results = [[recommender_class.__name__] + list(evaluate_train_test_split_implicit(
recommender, {'train': train_validation, 'test': test}, items_df, seed=seed))]
results = pd.DataFrame(results,
columns=['Recommender', 'HR@1', 'HR@3', 'HR@5', 'HR@10', 'NDCG@1', 'NDCG@3', 'NDCG@5', 'NDCG@10'])
display(HTML(results.to_html()))
return best_param_set
Tuning of the recommender
Task:
Tune your models using the code below. You only need to put the class name of your recommender and choose an appropriate parameter space.
param_space = {
'n_neg_per_pos': hp.quniform('n_neg_per_pos', 1, 10, 1)
}
best_param_set = tune_recommender(LinearRegressionCBUIRecommender, interactions_df, items_df,
param_space, max_evals=10, show_progressbar=True, seed=seed)
print("Best parameters:")
print(best_param_set)
param_space = {
'n_neg_per_pos': hp.quniform('n_neg_per_pos', 1, 10, 1),
'C': hp.loguniform('C', np.log(0.01), np.log(100.0))
}
best_param_set = tune_recommender(SVRCBUIRecommender, interactions_df, items_df,
param_space, max_evals=100, show_progressbar=True, seed=seed)
print("Best parameters:")
print(best_param_set)
param_space = {
'n_neg_per_pos': hp.quniform('n_neg_per_pos', 1, 10, 1),
'n_estimators': hp.quniform('n_estimators', 30, 300, 1),
'max_depth': hp.quniform('max_depth', 2, 10, 1),
'min_samples_split': hp.quniform('min_samples_split', 2, 30, 1)
}
best_param_set = tune_recommender(RandomForestCBUIRecommender, interactions_df, items_df,
param_space, max_evals=100, show_progressbar=True, seed=seed)
print("Best parameters:")
print(best_param_set)
# This tuning may take around 12 hours
param_space = {
'n_neg_per_pos': hp.quniform('n_neg_per_pos', 1, 10, 1),
'n_estimators': hp.quniform('n_estimators', 10, 300, 1),
'max_depth': hp.quniform('max_depth', 2, 10, 1),
'min_samples_split': hp.quniform('min_samples_split', 2, 30, 1),
'learning_rate': hp.loguniform('learning_rate', np.log(0.001), np.log(0.1))
}
best_param_set = tune_recommender(XGBoostCBUIRecommender, interactions_df, items_df,
param_space, max_evals=300, show_progressbar=True, seed=seed)
print("Best parameters:")
print(best_param_set)
Final evaluation
Task:
Run the final evaluation of your recommender and present its results against the Amazon recommender's results. You can present results for several of your recommenders. You just need to give the class name of your recommender and its tuned parameters below. If you present results for several recommenders, you should add a separate cell for each recommender and change the names of the DataFrames containing results.
cb_user_item_recommender = LinearRegressionCBUIRecommender(
**{'n_neg_per_pos': 7}) # Initialize your recommender here with the best params from tuning
# Give the name of your recommender in the line below
linear_cbui_tts_results = [['LinearRegressionCBUIRecommender'] + list(evaluate_train_test_split_implicit(
cb_user_item_recommender, interactions_df, items_df))]
linear_cbui_tts_results = pd.DataFrame(
linear_cbui_tts_results, columns=['Recommender', 'HR@1', 'HR@3', 'HR@5', 'HR@10', 'NDCG@1', 'NDCG@3', 'NDCG@5', 'NDCG@10'])
display(HTML(linear_cbui_tts_results.to_html()))
from recommenders.amazon_recommender import AmazonRecommender
amazon_recommender = AmazonRecommender()
amazon_tts_results = [['AmazonRecommender'] + list(evaluate_train_test_split_implicit(
amazon_recommender, interactions_df, items_df))]
amazon_tts_results = pd.DataFrame(
amazon_tts_results, columns=['Recommender', 'HR@1', 'HR@3', 'HR@5', 'HR@10', 'NDCG@1', 'NDCG@3', 'NDCG@5', 'NDCG@10'])
display(HTML(amazon_tts_results.to_html()))
tts_results = pd.concat([linear_cbui_tts_results, amazon_tts_results]).reset_index(drop=True)
display(HTML(tts_results.to_html()))