{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"pygments_lexer":"ipython3","nbconvert_exporter":"python","version":"3.6.4","file_extension":".py","codemirror_mode":{"name":"ipython","version":3},"name":"python","mimetype":"text/x-python"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Recommender using Factorization Machine","metadata":{}},{"cell_type":"code","source":"import pandas as pd\nimport numpy as np\nimport datetime\nfrom tqdm import tqdm","metadata":{"execution":{"iopub.status.busy":"2022-03-20T09:17:00.644558Z","iopub.execute_input":"2022-03-20T09:17:00.644945Z","iopub.status.idle":"2022-03-20T09:17:00.671122Z","shell.execute_reply.started":"2022-03-20T09:17:00.644832Z","shell.execute_reply":"2022-03-20T09:17:00.670462Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"## Load the data","metadata":{}},{"cell_type":"code","source":"# Load Data set\ntransactions = pd.read_csv(\"../input/h-and-m-personalized-fashion-recommendations/transactions_train.csv\", dtype={'article_id':str})\ntransactions.drop(['sales_channel_id', 'price'], inplace=True, axis=1)\n\n# Filter transactions by date\nstart_date = datetime.datetime(2020,9,15) # I replaced 1 by 15 to shorten the data set length\ntransactions[\"t_dat\"] = pd.to_datetime(transactions[\"t_dat\"])\ntransactions = transactions.loc[transactions[\"t_dat\"] >= start_date]\n\n# Filter transactions by number of an article has been bought\narticle_bought_count = transactions[['article_id', 't_dat']].groupby('article_id').count().reset_index().rename(columns={'t_dat': 'count'})\nmost_bought_articles = article_bought_count[article_bought_count['count']>10]['article_id'].values\ntransactions = transactions[transactions['article_id'].isin(most_bought_articles)]\n\ntransactions = transactions.reset_index(drop=True)\ntransactions.shape","metadata":{"execution":{"iopub.status.busy":"2022-03-20T09:17:08.033298Z","iopub.execute_input":"2022-03-20T09:17:08.033562Z","iopub.status.idle":"2022-03-20T09:18:27.966202Z","shell.execute_reply.started":"2022-03-20T09:17:08.033533Z","shell.execute_reply":"2022-03-20T09:18:27.965392Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"## Load customers and get last bought article for each one","metadata":{}},{"cell_type":"code","source":"# Get the customers to perform the predictions lately\ncustomers = pd.read_csv('../input/h-and-m-personalized-fashion-recommendations/sample_submission.csv').customer_id.values\n\n# Get the last bought articles for each customer in the filtered transactions\nlast_bought_articles = transactions.sort_values(['customer_id', 't_dat'], ascending=False).drop_duplicates(['customer_id'], keep='first')","metadata":{"execution":{"iopub.status.busy":"2022-03-20T09:26:47.213147Z","iopub.execute_input":"2022-03-20T09:26:47.213464Z","iopub.status.idle":"2022-03-20T09:26:50.38603Z","shell.execute_reply.started":"2022-03-20T09:26:47.213429Z","shell.execute_reply":"2022-03-20T09:26:50.385144Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"# Create an array with the last bought article for each customer in the whole customer set\ndef get_last_bought_article_per_customer(customers, last_bought_articles):\n    last_articles = []\n    transaction_customers = last_bought_articles.customer_id.values\n    for customer in tqdm(customers):\n        if customer in transaction_customers:\n            last_articles.append(last_bought_articles[last_bought_articles['customer_id'] == customer].article_id.values[0])\n        else:\n            last_articles.append(None)\n    return np.array(last_articles)","metadata":{"execution":{"iopub.status.busy":"2022-03-20T09:25:36.516314Z","iopub.execute_input":"2022-03-20T09:25:36.517303Z","iopub.status.idle":"2022-03-20T09:25:36.523993Z","shell.execute_reply.started":"2022-03-20T09:25:36.517222Z","shell.execute_reply":"2022-03-20T09:25:36.522675Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"# Retrieve the last bought articles\nlast_articles = get_last_bought_article_per_customer(customers, last_bought_articles)\nnp.save('data/last_articles.npy', last_articles)\n# last_articles = np.load('data/last_articles.npy')","metadata":{"execution":{"iopub.status.busy":"2022-03-20T09:25:41.163529Z","iopub.execute_input":"2022-03-20T09:25:41.163808Z","iopub.status.idle":"2022-03-20T09:26:03.434529Z","shell.execute_reply.started":"2022-03-20T09:25:41.163777Z","shell.execute_reply":"2022-03-20T09:26:03.43328Z"},"trusted":true},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"## Compute the default recommendation","metadata":{}},{"cell_type":"code","source":"# Calculate time decaying popularity. This leads to items bought more recently having more weight in the popularity list.\n# In simple words, item A bought 5 times on the first day of the train period is inferior than item B bought 4 times on the last day of the train period.\ntransactions['pop_factor'] = transactions['t_dat'].apply(lambda x: 1/(datetime.datetime(2020,9,23) - x).days)\ntransactions_by_article = transactions[['article_id', 'pop_factor']].groupby('article_id').sum().reset_index()\ndefault_recommendation =  transactions_by_article.sort_values(by='pop_factor', ascending=False)['article_id'].values[:12]","metadata":{},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"## Prepare data and features","metadata":{}},{"cell_type":"code","source":"# Sort the transactions by customer and date and assign the last item bought to each transaction\ntransactions = transactions.sort_values(['customer_id', 't_dat'], axis=0)\ntransactions['last_bought_id'] = pd.concat([pd.Series([transactions['article_id'].values[-1]]), transactions['article_id']])[:-1].values\ntransactions.drop(['t_dat', 'pop_factor'], inplace=True, axis=1)","metadata":{},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"# Retrieve the users and articles\ncustomer_values = np.unique(transactions.customer_id.values)\narticle_values = np.unique(transactions.article_id.values)\n\n# Create functions to map customer and article ids to indices\ncustomer_id2index = {c: i for i, c in enumerate(customer_values)}\narticle_id2index = {a: i for i, a in enumerate(article_values)}\n\ncustomer_index2id = {i: c for c, i in customer_id2index.items()}\narticle_index2id = {i: a for a, i in article_id2index.items()}","metadata":{},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"# Assign the customer and article indices to the transactions and drop the ids\ntransactions['customer_index'] = transactions.customer_id.map(customer_id2index)\ntransactions['article_index'] = transactions.article_id.map(article_id2index)\ntransactions['last_bought_index'] = transactions.last_bought_id.map(article_id2index)\n\ntransactions.drop(['customer_id', 'article_id', 'last_bought_id'], inplace=True, axis=1)","metadata":{},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"# TODO: Add some features if we want","metadata":{},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"transactions","metadata":{},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"## The model","metadata":{}},{"cell_type":"code","source":"import time","metadata":{},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"class RecSys_FM:\n    def __init__(self, transactions, customer_id2index, customer_index2id, article_id2index, article_index2id, default_recommendation, num_components=20):\n        # Save the transactions and split the labels\n        self.transactions = transactions\n\n        # Generate negative samples\n        self.negative_transactions = self.transactions.copy()\n\n        # Save the default recommendation\n        self.default_recommendation = default_recommendation\n\n        # Save customer and article mapping functions\n        self.customer_id2index = customer_id2index\n        self.customer_index2id = customer_index2id\n        self.article_id2index = article_id2index\n        self.article_index2id = article_index2id\n\n        # Compute the length of the one hot features\n        self.max_feature_values = self.transactions.max().values\n        self.dimensionality = self.max_feature_values.sum() + len(self.max_feature_values)\n\n        # Initialize the biases and parameters\n        self.global_bias = np.random.normal(scale=1, size=1)\n        self.biases = np.random.normal(scale=1/self.dimensionality, size=self.dimensionality)\n        self.params = np.random.normal(scale=1./self.dimensionality, size=(num_components, self.dimensionality))\n\n        # Create the list of indices\n        self.training_indices = np.arange(len(self.transactions)*2)\n\n    def __sgd__(self, lr, reg_w, reg_v):\n        \"\"\" Stochastic gradient descent \"\"\"\n        for idx in self.training_indices:\n            # Get the sample\n            if idx < len(self.transactions):\n                sample = self.transactions.iloc[idx]\n                bought = 1\n            else:\n                sample = self.negative_transactions.iloc[idx - len(self.transactions)]\n                bought = 0\n\n            # Get the one hot encoding positions for the current sample\n            sample_positions = []\n            accum = 0\n            for idx, col in enumerate(sample.index):\n                sample_positions.append(sample[col])\n                accum += self.max_feature_values[idx]\n            sample_positions = np.array(sample_positions)\n\n            # Make a prediction\n            prediction, summed = self.__predict__(sample_positions)\n\n            # Compute the error\n            error = self.__log_loss__(prediction, bought)\n\n            # Compute the gradient error\n            error_gradient = -bought / (np.exp(bought * prediction) + 1.0)\n\n            # Update biases and parameters\n            self.global_bias -= lr * error_gradient\n\n            self.biases[sample_positions] -= lr * (error_gradient + 2 * reg_w * self.biases[sample_positions])\n\n            self.params[:, sample_positions] -= lr * (error_gradient * (summed[:, np.newaxis] * self.params[:, sample_positions]) + 2 * reg_v * self.params[:, sample_positions])\n\n    def __predict__(self, sample_positions):\n        \"\"\" Make a prediction \"\"\"\n        # Compute the sum of the square component\n        summed = np.sum(self.params[:, sample_positions], axis=1)\n        summed_square = np.sum(self.params[:, sample_positions]**2, axis=1)\n        \n        # Return the prediction using the biases and parameters\n        return self.global_bias + np.sum(self.biases[sample_positions]) + 0.5 * np.sum(summed**2 - summed_square), summed\n\n    def __log_loss__(self, pred, real):\n        \"\"\" Log loss error \"\"\"\n        return np.log(np.exp(-pred * real) + 1.0)\n\n    def fit(self, n_epochs=10, learning_rate=0.001, reg_w=0.01, reg_v=0.001):\n        \"\"\" Train the model \"\"\"\n        for epoch in range(n_epochs):\n            print('Epoch:', epoch)\n            # Shuffle negative sample articles\n            self.__shuffle_negative_transactions__()\n            \n            # Shuffle the training indices\n            np.random.shuffle(self.training_indices)\n\n            # Run the SGD\n            self.__sgd__(learning_rate, reg_w, reg_v)\n\n    def __shuffle_negative_transactions__(self):\n        \"\"\" Shuffle negative samples \"\"\"\n        self.negative_transactions['article_index'] = self.negative_transactions['article_index'].sample(frac=1)\n\n    \n    def predict(self, customers, last_bought_articles):\n        \"\"\" Predict the articles to be recommended to each user \"\"\"\n        recommendations = []\n        \n        # Create the articles matrix\n        len_articles = len(self.article_index2id)\n        articles = np.eye(len_articles)\n\n        # Compute the matrix product between articles and the bias vector that apply to articles\n        len_customers = len(self.customer_index2id)\n        article_bias = np.dot(articles, self.biases[len_customers:len_customers+len_articles])\n\n        # Compute the matrix product between articles and the vectors from params that apply to articles\n        article_params = np.dot(articles, self.params[:, len_customers:len_customers+len_articles].T)\n\n        # Compute the matrix product between articles and the vectors from params to the square that apply to articles\n        article_square_params = np.dot(articles, self.params[:, len_customers:len_customers+len_articles].T**2)\n\n        for customer, last_bought_article in zip(customers, last_bought_articles):\n            if customer not in self.customer_id2index.keys():\n                # If the the customer is not in the trained ones return the default recommendation\n                recommendations.append(' '.join(default_recommendation))\n            else: # Else use the factorization machine\n                customer_idx = self.customer_id2index[customer]\n\n                last_bought_idx = self.article_id2index[last_bought_article] + len_customers + len_articles\n\n                # Make a prediction for each article using the one hot matrix\n                bias_product = self.biases[customer_idx] + article_bias + self.biases[last_bought_idx]\n                params_product = self.params[:, customer_idx] + article_params + self.params[:, last_bought_idx]\n                params_product_square = self.params[:, customer_idx]**2 + article_square_params + self.params[:, last_bought_idx]**2\n\n                predictions = self.global_bias + bias_product + 0.5 * np.sum(params_product**2 - params_product_square, axis=1)\n\n                # Sort the predictions and keep the 12 higher\n                recommended_indexes = predictions.argsort()[-12:]\n\n                # Keep the recommendations for this customer\n                recommendations.append(' '.join([self.article_index2id[item_idx] for item_idx in recommended_indexes]))\n            \n        return pd.DataFrame({\n            'customer_id': customers,\n            'prediction': recommendations,\n        })","metadata":{},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"recsys = RecSys_FM(transactions, customer_id2index, customer_index2id, article_id2index, article_index2id, default_recommendation, num_components=20)","metadata":{},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"recsys.fit()\nnp.save('data/params.npy', recsys.params)\nnp.save('data/biases.npy', recsys.biases)\nnp.save('data/global_bias.npy', recsys.global_bias)\n# recsys.params = np.load('data/params.npy')\n# recsys.biases = np.load('data/biases.npy')\n# recsys.global_bias = np.load('data/global_bias.npy')","metadata":{},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"# Make a prediction for each customer\npredictions = recsys.predict(customers, last_articles)","metadata":{},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"predictions.head()","metadata":{},"execution_count":null,"outputs":[]},{"cell_type":"code","source":"predictions.to_csv('submission.csv', index=False)","metadata":{},"execution_count":null,"outputs":[]},{"cell_type":"markdown","source":"https://www.csie.ntu.edu.tw/~b97053/paper/Rendle2010FM.pdf\n\nhttp://ethen8181.github.io/machine-learning/recsys/factorization_machine/factorization_machine.html","metadata":{}}]}