meh/recommender-systems-class-master/project_1_recommender_and_evaluation.ipynb
2021-07-07 20:03:54 +02:00

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()))