├── .gitignore ├── LICENSE ├── README.md ├── data └── ecomm │ └── OnlineRetail_sessions.pkl ├── notebooks ├── Analyze_HPO_results.ipynb └── Explore_Online_Retail_Dataset.ipynb ├── recsys ├── __init__.py ├── data.py ├── metrics.py ├── models.py └── utils.py ├── requirements.txt ├── requirements3.6.txt ├── scripts ├── baseline_analysis.py ├── setup_ray_cluster.py ├── train_w2v_with_logging.py └── tune_w2v_with_ray.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # data directory 141 | #data/ 142 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Session-based Recommender Systems 2 | 3 | This repo accompanies the Cloudera Fast Forward report [Session-based Recommender Systems](https://session-based-recommenders.fastforwardlabs.com/). It provides small library to train Word2Vec as a means of learning product or item representations in the context of user sessions (browsing histories, transaction histories, music playlists, etc.). These dense representations can then be used for item recommendation. We formulate this under the Next Event Prediction task, that is, given a user's recent interaction, predict the next item they interact with (click on, purchase, listen to, etc.). 4 | 5 | Instructions are given both for general use (on a laptop, say), and for Cloudera CML and CDSW. We'll first describe what's here, then go through how to run everything. 6 | 7 | ## Structure 8 | ``` 9 | . 10 | ├── data # This folder contains starter data. 11 | ├── scripts # This contains scripts for *doing* things -- training models, analysing results. 12 | ├── notebooks # This contains Jupyter notebooks that accompany the report and demonstrate basic usage. 13 | └── recsys # A small library of useful functions. 14 | ``` 15 | Let's examine each of the important folders in turn. 16 | 17 | 18 | ### `recsys` 19 | ``` 20 | ├── data.py # Contains functions for loading and processing data into sessions 21 | ├── metrics.py # Contains metrics for evaluation 22 | ├── models.py # Contains wrappers for training Word2Vec both alone and with Ray Tune 23 | └── utils.py # Helper functions for serialization and I/O 24 | ``` 25 | 26 | 27 | ### `scripts` 28 | ``` 29 | ├── baseline_analysis.py 30 | ├── setup_ray_cluster.py 31 | ├── train_w2v_with_logging.py 32 | └── tune_w2v_with_ray.py 33 | ``` 34 | An overview of what each of these scripts does is discussed below. 35 | 36 | ### `notebooks` 37 | ``` 38 | ├── Analyze_HPO_results.ipynb 39 | └── Explore_Online_Retail_Dataset.ipynb 40 | ``` 41 | These notebooks provide additional exploration and analysis. Please note that `Analyze_HPO_results.ipynb` is expressly for demonstration purposes as HPO output results explored within are not included in this repo. 42 | 43 | ## Learning representations for session-based recommendations 44 | To go from a fresh clone of the repo to the final state, follow these instructions in order. 45 | 46 | ### Installation 47 | The code and applications within were developed against Python 3.8.8, and are likely also to function with more recent versions of Python. 48 | 49 | To install dependencies, first create and activate a new virtual environment through your preferred means, then pip install from the requirements file. I recommend: 50 | 51 | ``` 52 | python3 -m venv .venv 53 | source .venv/bin/activate 54 | pip install -r requirements.txt 55 | ``` 56 | 57 | In CML or CDSW, no virtual env is necessary. Instead, inside a Python 3 session (with at least 2 vCPU / 4 GiB Memory), simply run 58 | 59 | ``` 60 | !pip3 install -r requirements.txt # notice `pip3`, not `pip` 61 | ``` 62 | 63 | Note: if your session has an older Python image (3.6) use the alternative `requirements3.6.txt`: 64 | ``` 65 | !pip3 install -r requirements3.6.txt 66 | ``` 67 | 68 | ### Data 69 | 70 | While we explored several datasets (and code exists in `recsys/data.py` to interact with those datasets), the analysis in this repo is focused on the [Online Retail](https://www.kaggle.com/vijayuv/onlineretail) dataset. This dataset is open source though you will need to create an account on Kaggle before downloading the data. In this repo we include a version of this dataset post-processed into customer sessions. These sessions represent all customer transactions from a UK-based online boutique selling specialty gifts collected between 12/01/2010 and 12/09/2011. In total there are purchase histories for 4,372 customers and 3,684 unique products. 71 | 72 | ### Model training and analysis 73 | 74 | The `scripts` directory contains scripts to train models in various formats and analyze results. Here we provide a high-level overview: 75 | 76 | * `scripts/baseline_analysis.py`: a common baseline for recommendation systems is to simply recommend the most popular items. This script computes the "Association Rules" baseline which considers how frequently each item co-occurrs with all other items in a session for each session in the training set. 77 | * `scripts/train_w2v_with_logging.py`: This script trains Gensim's implementation of the Word2Vec algorithm to learn representations for each item in a session. Identifying "similar" items then serves as the method for generating recommendations. Includes callbacks for monitoring metrics (Recall@K, training loss) as a function of training time (epochs). 78 | * `scripts/tune_w2v_with_ray.py`: The Word2Vec algorithm has a large hyperparameter space and the default values are subpar for the task of generating good item representations for recommendation systems. This scripts performs hyperparameter optimization (HPO) with [Ray Tune](https://docs.ray.io/en/master/tune/index.html). 79 | * [CDSW/CML only] `setup_ray_cluster.py`: Hyperparameter optimization can be computationally expensive but this expense can be mitigated, in part, through distribution. This script initializes (and tears down) a Ray Cluster for distributed hyperparameter optimization. If using, follow the instructions in this script to setup the cluster, then run `tune_w2v_with_ray.py` with the appropriate arguments, and finally shutdown the cluster after HPO is complete. 80 | 81 | 82 | These scripts are not intended to be run in any particular order (with the exception noted above). Instead, they provide functionality for different use cases. To run scripts, follow this procedure in the terminal or a Session with at least 1vCPUs and 2GiBs of memory: 83 | 84 | ``` 85 | !python3 scripts/baseline_analysis.py 86 | !python3 scripts/train_w2v_with_logging.py # see optional arguments 87 | !python3 scriptstune_w2v_with_ray.py # see optional arguments for distributed HPO 88 | ``` 89 | 90 | -------------------------------------------------------------------------------- /data/ecomm/OnlineRetail_sessions.pkl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastforwardlabs/session_based_recommenders/c438dd1334fcefc6bedea69b0cd67f779a5de5d3/data/ecomm/OnlineRetail_sessions.pkl -------------------------------------------------------------------------------- /notebooks/Explore_Online_Retail_Dataset.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# The \"Online Retail\" Dataset \n", 8 | "\n", 9 | "This is a transnational data set which contains all purchase transactions occurring between 01/12/2010 and 09/12/2011 for a UK-based and registered non-store online retail. The company mainly sells unique all-occasion gifts. Many customers of the company are wholesalers. \n", 10 | "\n", 11 | "The dataset is composed of the following columns:\n", 12 | "\n", 13 | "\n", 14 | "* **InvoiceNo**: Invoice number. Nominal, a 6-digit integral number uniquely assigned to each transaction. If this code starts with letter 'c', it indicates a cancellation.\n", 15 | "* **StockCode**: Product (item) code. Nominal, a 5-digit integral number uniquely assigned to each distinct product.\n", 16 | "* **Description**: Product (item) name. Nominal.\n", 17 | "* **Quantity**: The quantities of each product (item) per transaction. Numeric.\n", 18 | "* **InvoiceDate**: Invice Date and time. Numeric, the day and time when each transaction was generated.\n", 19 | "* **UnitPrice**: Unit price. Numeric, Product price per unit in sterling.\n", 20 | "* **CustomerID**: Customer number. Nominal, a 5-digit integral number uniquely assigned to each customer.\n", 21 | "* **Country**: Country name. Nominal, the name of the country where each customer resides." 22 | ] 23 | }, 24 | { 25 | "cell_type": "code", 26 | "execution_count": 22, 27 | "metadata": {}, 28 | "outputs": [], 29 | "source": [ 30 | "import pandas as pd\n", 31 | "import numpy as np\n", 32 | "import matplotlib.pyplot as plt\n", 33 | "import seaborn as sns\n", 34 | "\n", 35 | "from recsys.data import load_original_ecomm, preprocess_ecomm, construct_session_sequences " 36 | ] 37 | }, 38 | { 39 | "cell_type": "code", 40 | "execution_count": 23, 41 | "metadata": {}, 42 | "outputs": [], 43 | "source": [ 44 | "import seaborn as sns\n", 45 | "plt.style.use(\"seaborn-white\")\n", 46 | "cldr_colors = ['#00b6b5', '#f7955b','#6c8cc7', '#828282']#\n", 47 | "cldr_green = '#a4d65d'\n", 48 | "color_palette = \"viridis\"" 49 | ] 50 | }, 51 | { 52 | "cell_type": "markdown", 53 | "metadata": {}, 54 | "source": [ 55 | "## Getting the dataset\n", 56 | "\n", 57 | "We obtained the Online Retail dataset from the Kaggle website found [here](https://www.kaggle.com/vijayuv/onlineretail). \n", 58 | "The data is open source but you will need to register on Kaggle's website before downloading. \n", 59 | "\n", 60 | "Once downloaded, we created some helper functions for opening and processing this dataset. " 61 | ] 62 | }, 63 | { 64 | "cell_type": "code", 65 | "execution_count": 39, 66 | "metadata": {}, 67 | "outputs": [], 68 | "source": [ 69 | "df = load_original_ecomm()" 70 | ] 71 | }, 72 | { 73 | "cell_type": "code", 74 | "execution_count": 25, 75 | "metadata": {}, 76 | "outputs": [ 77 | { 78 | "data": { 79 | "text/html": [ 80 | "
\n", 81 | "\n", 94 | "\n", 95 | " \n", 96 | " \n", 97 | " \n", 98 | " \n", 99 | " \n", 100 | " \n", 101 | " \n", 102 | " \n", 103 | " \n", 104 | " \n", 105 | " \n", 106 | " \n", 107 | " \n", 108 | " \n", 109 | " \n", 110 | " \n", 111 | " \n", 112 | " \n", 113 | " \n", 114 | " \n", 115 | " \n", 116 | " \n", 117 | " \n", 118 | " \n", 119 | " \n", 120 | " \n", 121 | " \n", 122 | " \n", 123 | " \n", 124 | " \n", 125 | " \n", 126 | " \n", 127 | " \n", 128 | " \n", 129 | " \n", 130 | " \n", 131 | " \n", 132 | " \n", 133 | " \n", 134 | " \n", 135 | " \n", 136 | " \n", 137 | " \n", 138 | " \n", 139 | " \n", 140 | " \n", 141 | " \n", 142 | " \n", 143 | " \n", 144 | " \n", 145 | " \n", 146 | " \n", 147 | " \n", 148 | " \n", 149 | " \n", 150 | " \n", 151 | " \n", 152 | " \n", 153 | " \n", 154 | " \n", 155 | " \n", 156 | " \n", 157 | " \n", 158 | " \n", 159 | " \n", 160 | " \n", 161 | " \n", 162 | " \n", 163 | " \n", 164 | " \n", 165 | " \n", 166 | " \n", 167 | " \n", 168 | " \n", 169 | " \n", 170 | " \n", 171 | " \n", 172 | " \n", 173 | " \n", 174 | " \n", 175 | " \n", 176 | " \n", 177 | " \n", 178 | " \n", 179 | " \n", 180 | " \n", 181 | " \n", 182 | " \n", 183 | " \n", 184 | " \n", 185 | " \n", 186 | " \n", 187 | " \n", 188 | " \n", 189 | " \n", 190 | " \n", 191 | " \n", 192 | " \n", 193 | " \n", 194 | " \n", 195 | " \n", 196 | " \n", 197 | " \n", 198 | " \n", 199 | " \n", 200 | " \n", 201 | " \n", 202 | " \n", 203 | " \n", 204 | " \n", 205 | " \n", 206 | " \n", 207 | " \n", 208 | " \n", 209 | " \n", 210 | " \n", 211 | " \n", 212 | " \n", 213 | " \n", 214 | " \n", 215 | " \n", 216 | " \n", 217 | " \n", 218 | " \n", 219 | " \n", 220 | " \n", 221 | " \n", 222 | " \n", 223 | " \n", 224 | " \n", 225 | " \n", 226 | " \n", 227 | " \n", 228 | " \n", 229 | " \n", 230 | " \n", 231 | " \n", 232 | " \n", 233 | " \n", 234 | " \n", 235 | " \n", 236 | " \n", 237 | " \n", 238 | " \n", 239 | " \n", 240 | " \n", 241 | " \n", 242 | " \n", 243 | " \n", 244 | " \n", 245 | " \n", 246 | " \n", 247 | " \n", 248 | " \n", 249 | " \n", 250 | " \n", 251 | " \n", 252 | " \n", 253 | " \n", 254 | " \n", 255 | " \n", 256 | " \n", 257 | " \n", 258 | " \n", 259 | " \n", 260 | " \n", 261 | " \n", 262 | " \n", 263 | " \n", 264 | " \n", 265 | " \n", 266 | " \n", 267 | " \n", 268 | " \n", 269 | " \n", 270 | " \n", 271 | " \n", 272 | " \n", 273 | " \n", 274 | " \n", 275 | " \n", 276 | " \n", 277 | " \n", 278 | " \n", 279 | " \n", 280 | " \n", 281 | " \n", 282 | " \n", 283 | " \n", 284 | " \n", 285 | " \n", 286 | " \n", 287 | " \n", 288 | " \n", 289 | " \n", 290 | " \n", 291 | " \n", 292 | " \n", 293 | " \n", 294 | " \n", 295 | " \n", 296 | " \n", 297 | " \n", 298 | " \n", 299 | " \n", 300 | " \n", 301 | " \n", 302 | " \n", 303 | " \n", 304 | " \n", 305 | " \n", 306 | " \n", 307 | " \n", 308 | " \n", 309 | " \n", 310 | " \n", 311 | " \n", 312 | " \n", 313 | " \n", 314 | " \n", 315 | " \n", 316 | " \n", 317 | " \n", 318 | " \n", 319 | " \n", 320 | " \n", 321 | " \n", 322 | " \n", 323 | " \n", 324 | " \n", 325 | " \n", 326 | " \n", 327 | " \n", 328 | " \n", 329 | " \n", 330 | "
InvoiceNoStockCodeDescriptionQuantityInvoiceDateUnitPriceCustomerIDCountry
053636585123AWHITE HANGING HEART T-LIGHT HOLDER62010-12-01 08:26:002.5517850.0United Kingdom
153636571053WHITE METAL LANTERN62010-12-01 08:26:003.3917850.0United Kingdom
253636584406BCREAM CUPID HEARTS COAT HANGER82010-12-01 08:26:002.7517850.0United Kingdom
353636584029GKNITTED UNION FLAG HOT WATER BOTTLE62010-12-01 08:26:003.3917850.0United Kingdom
453636584029ERED WOOLLY HOTTIE WHITE HEART.62010-12-01 08:26:003.3917850.0United Kingdom
553636522752SET 7 BABUSHKA NESTING BOXES22010-12-01 08:26:007.6517850.0United Kingdom
653636521730GLASS STAR FROSTED T-LIGHT HOLDER62010-12-01 08:26:004.2517850.0United Kingdom
753636622633HAND WARMER UNION JACK62010-12-01 08:28:001.8517850.0United Kingdom
853636622632HAND WARMER RED POLKA DOT62010-12-01 08:28:001.8517850.0United Kingdom
953636784879ASSORTED COLOUR BIRD ORNAMENT322010-12-01 08:34:001.6913047.0United Kingdom
1053636722745POPPY'S PLAYHOUSE BEDROOM62010-12-01 08:34:002.1013047.0United Kingdom
1153636722748POPPY'S PLAYHOUSE KITCHEN62010-12-01 08:34:002.1013047.0United Kingdom
1253636722749FELTCRAFT PRINCESS CHARLOTTE DOLL82010-12-01 08:34:003.7513047.0United Kingdom
1353636722310IVORY KNITTED MUG COSY62010-12-01 08:34:001.6513047.0United Kingdom
1453636784969BOX OF 6 ASSORTED COLOUR TEASPOONS62010-12-01 08:34:004.2513047.0United Kingdom
1553636722623BOX OF VINTAGE JIGSAW BLOCKS32010-12-01 08:34:004.9513047.0United Kingdom
1653636722622BOX OF VINTAGE ALPHABET BLOCKS22010-12-01 08:34:009.9513047.0United Kingdom
1753636721754HOME BUILDING BLOCK WORD32010-12-01 08:34:005.9513047.0United Kingdom
1853636721755LOVE BUILDING BLOCK WORD32010-12-01 08:34:005.9513047.0United Kingdom
1953636721777RECIPE BOX WITH METAL HEART42010-12-01 08:34:007.9513047.0United Kingdom
\n", 331 | "
" 332 | ], 333 | "text/plain": [ 334 | " InvoiceNo StockCode Description Quantity \\\n", 335 | "0 536365 85123A WHITE HANGING HEART T-LIGHT HOLDER 6 \n", 336 | "1 536365 71053 WHITE METAL LANTERN 6 \n", 337 | "2 536365 84406B CREAM CUPID HEARTS COAT HANGER 8 \n", 338 | "3 536365 84029G KNITTED UNION FLAG HOT WATER BOTTLE 6 \n", 339 | "4 536365 84029E RED WOOLLY HOTTIE WHITE HEART. 6 \n", 340 | "5 536365 22752 SET 7 BABUSHKA NESTING BOXES 2 \n", 341 | "6 536365 21730 GLASS STAR FROSTED T-LIGHT HOLDER 6 \n", 342 | "7 536366 22633 HAND WARMER UNION JACK 6 \n", 343 | "8 536366 22632 HAND WARMER RED POLKA DOT 6 \n", 344 | "9 536367 84879 ASSORTED COLOUR BIRD ORNAMENT 32 \n", 345 | "10 536367 22745 POPPY'S PLAYHOUSE BEDROOM 6 \n", 346 | "11 536367 22748 POPPY'S PLAYHOUSE KITCHEN 6 \n", 347 | "12 536367 22749 FELTCRAFT PRINCESS CHARLOTTE DOLL 8 \n", 348 | "13 536367 22310 IVORY KNITTED MUG COSY 6 \n", 349 | "14 536367 84969 BOX OF 6 ASSORTED COLOUR TEASPOONS 6 \n", 350 | "15 536367 22623 BOX OF VINTAGE JIGSAW BLOCKS 3 \n", 351 | "16 536367 22622 BOX OF VINTAGE ALPHABET BLOCKS 2 \n", 352 | "17 536367 21754 HOME BUILDING BLOCK WORD 3 \n", 353 | "18 536367 21755 LOVE BUILDING BLOCK WORD 3 \n", 354 | "19 536367 21777 RECIPE BOX WITH METAL HEART 4 \n", 355 | "\n", 356 | " InvoiceDate UnitPrice CustomerID Country \n", 357 | "0 2010-12-01 08:26:00 2.55 17850.0 United Kingdom \n", 358 | "1 2010-12-01 08:26:00 3.39 17850.0 United Kingdom \n", 359 | "2 2010-12-01 08:26:00 2.75 17850.0 United Kingdom \n", 360 | "3 2010-12-01 08:26:00 3.39 17850.0 United Kingdom \n", 361 | "4 2010-12-01 08:26:00 3.39 17850.0 United Kingdom \n", 362 | "5 2010-12-01 08:26:00 7.65 17850.0 United Kingdom \n", 363 | "6 2010-12-01 08:26:00 4.25 17850.0 United Kingdom \n", 364 | "7 2010-12-01 08:28:00 1.85 17850.0 United Kingdom \n", 365 | "8 2010-12-01 08:28:00 1.85 17850.0 United Kingdom \n", 366 | "9 2010-12-01 08:34:00 1.69 13047.0 United Kingdom \n", 367 | "10 2010-12-01 08:34:00 2.10 13047.0 United Kingdom \n", 368 | "11 2010-12-01 08:34:00 2.10 13047.0 United Kingdom \n", 369 | "12 2010-12-01 08:34:00 3.75 13047.0 United Kingdom \n", 370 | "13 2010-12-01 08:34:00 1.65 13047.0 United Kingdom \n", 371 | "14 2010-12-01 08:34:00 4.25 13047.0 United Kingdom \n", 372 | "15 2010-12-01 08:34:00 4.95 13047.0 United Kingdom \n", 373 | "16 2010-12-01 08:34:00 9.95 13047.0 United Kingdom \n", 374 | "17 2010-12-01 08:34:00 5.95 13047.0 United Kingdom \n", 375 | "18 2010-12-01 08:34:00 5.95 13047.0 United Kingdom \n", 376 | "19 2010-12-01 08:34:00 7.95 13047.0 United Kingdom " 377 | ] 378 | }, 379 | "execution_count": 25, 380 | "metadata": {}, 381 | "output_type": "execute_result" 382 | } 383 | ], 384 | "source": [ 385 | "df.head(20)" 386 | ] 387 | }, 388 | { 389 | "cell_type": "markdown", 390 | "metadata": {}, 391 | "source": [ 392 | "These purchase histories record transactions for each customer and detail the items that were purchased in each transaction. Each transaction has a unique InvoiceNo, a time stamp (InvoiceDate) and the CustomerID of the purchaser. \n", 393 | "\n", 394 | "## Preprocessing\n", 395 | "\n", 396 | "There are some rows with missing information, so we'll filter those out. Since we want to define customer sessions, we'll use group by CustomerID field and filter out any customer entries that have fewer than three purchased items." 397 | ] 398 | }, 399 | { 400 | "cell_type": "code", 401 | "execution_count": 47, 402 | "metadata": {}, 403 | "outputs": [ 404 | { 405 | "data": { 406 | "text/plain": [ 407 | "InvoiceNo 0\n", 408 | "StockCode 0\n", 409 | "Description 1454\n", 410 | "Quantity 0\n", 411 | "InvoiceDate 0\n", 412 | "UnitPrice 0\n", 413 | "CustomerID 135080\n", 414 | "Country 0\n", 415 | "dtype: int64" 416 | ] 417 | }, 418 | "execution_count": 47, 419 | "metadata": {}, 420 | "output_type": "execute_result" 421 | } 422 | ], 423 | "source": [ 424 | "df.isnull().sum()" 425 | ] 426 | }, 427 | { 428 | "cell_type": "code", 429 | "execution_count": 48, 430 | "metadata": {}, 431 | "outputs": [], 432 | "source": [ 433 | "df.dropna(inplace=True)" 434 | ] 435 | }, 436 | { 437 | "cell_type": "code", 438 | "execution_count": 49, 439 | "metadata": {}, 440 | "outputs": [], 441 | "source": [ 442 | "# filter out sessions that have fewer than 3 items\n", 443 | "item_counts = df.groupby([\"CustomerID\"]).count()[\"StockCode\"]\n", 444 | "df = df[\n", 445 | " df[\"CustomerID\"].isin(item_counts[item_counts >= 3].index)\n", 446 | "].reset_index(drop=True)" 447 | ] 448 | }, 449 | { 450 | "cell_type": "code", 451 | "execution_count": 50, 452 | "metadata": {}, 453 | "outputs": [ 454 | { 455 | "data": { 456 | "text/plain": [ 457 | "CustomerID\n", 458 | "12346.0 2\n", 459 | "12347.0 182\n", 460 | "12348.0 31\n", 461 | "12349.0 73\n", 462 | "12350.0 17\n", 463 | " ... \n", 464 | "18280.0 10\n", 465 | "18281.0 7\n", 466 | "18282.0 13\n", 467 | "18283.0 756\n", 468 | "18287.0 70\n", 469 | "Name: StockCode, Length: 4372, dtype: int64" 470 | ] 471 | }, 472 | "execution_count": 50, 473 | "metadata": {}, 474 | "output_type": "execute_result" 475 | } 476 | ], 477 | "source": [ 478 | "item_counts" 479 | ] 480 | }, 481 | { 482 | "cell_type": "markdown", 483 | "metadata": {}, 484 | "source": [ 485 | "## Dataset Statistics" 486 | ] 487 | }, 488 | { 489 | "cell_type": "code", 490 | "execution_count": 29, 491 | "metadata": {}, 492 | "outputs": [ 493 | { 494 | "data": { 495 | "text/plain": [ 496 | "4234" 497 | ] 498 | }, 499 | "execution_count": 29, 500 | "metadata": {}, 501 | "output_type": "execute_result" 502 | } 503 | ], 504 | "source": [ 505 | "# Number of unique customers after preprocessing\n", 506 | "df.CustomerID.nunique()" 507 | ] 508 | }, 509 | { 510 | "cell_type": "code", 511 | "execution_count": 30, 512 | "metadata": {}, 513 | "outputs": [ 514 | { 515 | "data": { 516 | "text/plain": [ 517 | "3684" 518 | ] 519 | }, 520 | "execution_count": 30, 521 | "metadata": {}, 522 | "output_type": "execute_result" 523 | } 524 | ], 525 | "source": [ 526 | "# Number of unique stock codes (products)\n", 527 | "df.StockCode.nunique()" 528 | ] 529 | }, 530 | { 531 | "cell_type": "markdown", 532 | "metadata": {}, 533 | "source": [ 534 | "### Product popularity\n", 535 | "\n", 536 | "Here we plot the frequency by which each product is purchased (occurs in a transaction). " 537 | ] 538 | }, 539 | { 540 | "cell_type": "code", 541 | "execution_count": 31, 542 | "metadata": {}, 543 | "outputs": [ 544 | { 545 | "data": { 546 | "image/png": "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\n", 547 | "text/plain": [ 548 | "
" 549 | ] 550 | }, 551 | "metadata": {}, 552 | "output_type": "display_data" 553 | } 554 | ], 555 | "source": [ 556 | "plt.style.use(\"seaborn-white\")\n", 557 | "\n", 558 | "# Number of unique customer IDs\n", 559 | "product_counts = df.groupby(['StockCode']).count()['InvoiceNo'].values\n", 560 | "\n", 561 | "fig = plt.figure(figsize=(8,6))\n", 562 | "plt.yticks(fontsize=14)\n", 563 | "plt.xticks(fontsize=14)\n", 564 | "\n", 565 | "plt.semilogy(sorted(product_counts))\n", 566 | "plt.ylabel(\"Product counts\", fontsize=16);\n", 567 | "plt.xlabel(\"Product index\", fontsize=16);\n", 568 | "\n", 569 | "plt.tight_layout()" 570 | ] 571 | }, 572 | { 573 | "cell_type": "markdown", 574 | "metadata": {}, 575 | "source": [ 576 | "The left side of the figure corresponds to products that are not very popular (because they aren't purchased very often), while the far right side indicates that some products are *extremely* popular and have been purchased hundreds of times. \n", 577 | "\n", 578 | "### Customer session lengths \n", 579 | "\n", 580 | "We define a customer's \"session\" as all the products they purchased in each transaction, in the order in which they were purchased (ordered InvoiceDate). We can then examine statistics regarding the length of these sessions. Below is a boxplot of all customer session lengths. " 581 | ] 582 | }, 583 | { 584 | "cell_type": "code", 585 | "execution_count": 78, 586 | "metadata": {}, 587 | "outputs": [ 588 | { 589 | "data": { 590 | "image/png": "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jRgxwqmHn5+f135ctb+QupXty5v09PQqtTl8+PBy27lqk5aWVm571zpJHuP28fFRWlpalfeba3vX8319fcvVx8fHx6OGZcfgrW7u4/PG2/52r2dF86uMt1qVrY+3ds+3LyubS3Uh4AA1LCYmpso/HGuKt9/CLkXdunW9/v98evbsqR49epRbfjFvwhXNx3V2wbVNy5YtFRISoqSkpArbqmj8derU8Xjcq1cvj2WVjTs8PFw9evSQj4+PkpKSFBISopCQEPXs2bPcdj4+Ph5zCg8PV1JSUrnl7nr27OnM1b0d9766d+/u8RzXmN3HXtH83dtz329JSUmKiIhQ9+7dnfVl6+tee9d2ZefhXsu6det67cs1BtcYw8PDFRMT49Gm+3r39n7zm9+U285Vm4iICKd27n27PrrsPu6kpCS1bNmy3H5zjcdbvVq2bOk8v0ePHl7H4V7Divadt3UV8Xac9+jRwzmOKpqftzm419vbx8TL1qeiurkfwxcyl+pCwAEuA9cP02bNmqlFixYKCAgo99uN6wUfFhampk2bOh95btWqlfP4vvvukyT5+/vL399fAQEBat68uZo1a+bxG5n7b43NmzfXiBEjFBER4dFv48aNneBVq1Yt+fv7y8/PTz4+PqpXr54kqWHDhmrRooUCAwPVqFEjp+/09HS1atVKQUFBSk9PV5s2bdSiRQsFBQXp9ttvd8bh4+Mjf39/tWrVyvnNtEWLFs76gIAADR8+XAEBAfLx8VHjxo0lSaGhoc56V3uNGjVSYGCgIiIiNGLECLVp00bh4eEefQ4aNEhpaWmqXbu2x2+effv2VZs2bdS8eXMFBAQ49QsMDNTIkSOdtgICAhQQEKBWrVpp5MiRCgoKUosWLdSmTRvdddddGjlypFPn1NRUp83AwEA1b95cERERzphcZzAiIyM9flvt27ev84PetW8iIyOdfVT2uWlpac5y102ZLVq0cM5EBAUFOTXx1pd7nYYPH67atWtr+PDhzvPS09MVGBiogIAA53uXyrbnasdVB1fbZde7z919DO7zCwwMdOobGBiowMBApaene+3LNYb09HSPfepeG/f1gwYNko+Pj3PWtOx27rVxHSfufbsru+9c+00693pu06aN0tLSyo21ovp4G8f5xudeN2/j86Zv375q1aqVU2P3viuan+u15JqPt3pX1FdldSs7B2/z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atGihRo3bixJ+vzzzy/5a+XfeustZWdna8qUKYqIiCj3V39tsWTJEtWpU+dKD+MXITQ0VHfddZcmTZqkF1988UoPBz9TBByc18KFC5WYmKhJkyY5y7p06aLWrVtr+PDh2rBhw2ULHU2bNlXTpk0vS18/d1u3btXWrVuVkZHhsbxsoPn88891//33X1Jfx48fV+3atdW3b99Lagdw9+CDDyoxMVFffPGF2rdvf6WHg58hLlHhvI4ePer1voPExESNGTNGTZo0cZYdOXJEY8eOVefOnRUfH6/U1FTt37/fWX/27FlNnTpVPXv2VExMjG699Va99tprVV5f9rJDcXGx5s+fr969eys2Nla333671qxZ46w/cOCAoqKi9PHHH+vhhx9WXFycunfvrszMzAuqQUlJiWbMmKGePXsqNjZWd999tzZt2uSs37Jli6KiorRt2zb169dPsbGxSklJ0RtvvOHRzpdffqkHHnhAHTt2VEpKit566y3deOONmjVrlg4cOKCUlBRJ0ujRozVw4EDneWfOnNHTTz+tzp07KyEhQePGjav08uCiRYvUq1cvBQUFSZKioqIUFRWl+fPna82aNc7jvLw8PfTQQ5o1a1aFbX344Ye655571LFjR/Xo0UMZGRkqLi6WdO5y3qxZs3T69GlFRUVV2E6vXr00Z84c/fGPf1R8fLy6deummTNnqrS01NkmKipK8+bNU58+fXT99dfr/fffl3QurPXv31/XXXedunbtqmeffVYFBQUe7b/88stKTk5Wx44d9fjjj+vMmTPl+i97eWbSpEnq1auX8/js2bOaN2+ebrjhBsXFxenOO+/URx995Mzzs88+U1ZWlqKionTgwIFKj1dvoqKitGLFCo0YMUJxcXHq1auXli1b5qx3HbOuubvceeed+sMf/iDpv8fbihUr1K1bN/Xo0UMHDhyQJK1cuVJ9+vRRhw4ddPPNN+v111/3aKeyY+nkyZOaOHGikpOTFRMTo9/85jcaN26cfvzxR2ebHTt2qH///oqPj1fnzp01atQoHTx40KOfV199VTfddJNiYmLUp08fvfvuux7r8/LyNGrUKCUkJKh79+5avXp1uVrVq1dPiYmJevnll89bU6BC1f23JmAX1993Gj58uHn77bfN4cOHvW53+vRpc+utt5pevXqZt956y3zwwQfmnnvuMUlJSSY/P98YY8xLL71kOnfubN58802zefNm89xzz5nIyEiTnZ1dpfUzZ840HTt2dPocM2aMiYuLMwsXLjTr168348ePN5GRkeb11183xpz7A22RkZGmc+fOZsaMGWbjxo3ONllZWRXOecCAAWbYsGHO4z/84Q8mLi7OvPzyy2bdunXmscceM9HR0Wb79u3GGGM2b95sIiMjTffu3c2iRYvMxo0bTXp6uomMjDS7d+82xhiTl5dnOnXqZO677z6zdu1a89prr5lOnTqZ6OhoM3PmTFNYWGg++OADExkZaebNm+c8Lzk52bRt29Y88sgj5tNPPzWLFy827dq1M5MnT65w/CdOnDDt27c3H3zwgbMsJyfH5OTkmA4dOpg33njD5OTkmMzMTJOcnGxycnLMoUOHvLa1YsUKExkZaSZMmGDWr19v5s+fb2JjY81jjz1mjDFm9+7d5oknnjAdOnQ4bzvJyckmISHBDB482GRlZZk5c+aY9u3bm2nTpjnbREZGmujoaLN8+XLz7rvvmry8PJOVlWXatm1rRo8ebbKyssxf/vIX07lzZ9O/f3/nD+0tXLjQtGvXzsyYMcOsW7fOjB492kRHR3scK8nJyeaZZ57xGNPEiRNNcnKy8/hPf/qTiY6ONnPmzHGOlXbt2pmtW7ea3bt3m7vuusv069fP5OTkmMLCwkqPV28iIyNNQkKCefTRR826deuc56xcudIY899j9r333vN43h133GHGjRtnjPE83tauXWvefPNNY4wxixYtMlFRUWby5Mnm008/NdOnTzeRkZFmzZo1Tg0qO5aGDRtmkpOTzZo1a8zmzZvNSy+9ZNq3b+9sc+rUKdO5c2czZswYs3HjRvP++++blJQUc9999zltzJo1y7Rv395Mnz7drF+/3kycONFERUWZd9991xhz7g+F3n777SY5Odm888475u233zbJycmmffv25fbR3/72N9OhQwdTWFhYYU2BinCJCuc1ZswY5efna/Xq1frkk08kSa1bt1bv3r310EMPOfd3rF69Wt98843WrFmjNm3aSDp3KSs5OVlLly5Venq6tm3bppiYGN11112SpOuvv15BQUGqXbu2JFW63t1XX32ld955R88884z69esnSerWrZtOnjypadOm6e6773a2veWWWzRq1Cinzf/3//6fsrOzq3Rpbc+ePfrb3/6miRMn6t5775UkJSUl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8ODBmjx5siTp6quv1gMPPKAbbrhBhw4dqtJ6dwkJCZJU7tMm7777rho1auScNbpUCQkJMsaooKDAmVdsbKw2bdqkV155pUqXmCSpU6dO+uyzzzzOemRnZztvaJKq7fuEmjVrJkkelzxiY2N16NAhde/eXbGxsfL19VV4eLh+/etfKzY2tsI3owYNGnitsaQqvWm6y87O9jhb8NFHHyk4OLjCjwDXrVtX7dq1K9f/+vXrdeLECV133XW65pprFBYWpg8++KBcX+6Cg4N1+PBh53FpaalycnKcxx06dJCfn59zn5nLU0895XyKp1Ytzx+XF3K8unO9HlzWrl2r1q1bKywszDmD4z7W3Nxc51NSFXFdOirb9owZMzR16tTzPtfliy++UHFxsYYNG+aEm1OnTmn79u3OfsvOzlaXLl109OhRBQQEqEuXLho/frwk6bvvvlNcXJz8/f115MgRj9fL7t27NWfOHEnn7us5ceKExycRv/nmG+3bt6/cmHJzcyX995gGLgRncFChkJAQjRkzRpMnT1Z+fr769u2rpk2b6vDhw1qxYoVyc3M1e/ZsSdL//M//aOnSpRo8eLCGDRumkJAQrVy5Uh988IHuuOMOSefCQmZmpkJDQxUbG6s9e/bo/fff14MPPlil9e7atm2r3r17a8qUKSooKFBUVJTWrl2rd955R0899VS5N6OL1a5dO/Xu3VuPP/640tPT1aZNG3322WfKzMzUkCFDqtzPwIEDtWzZMg0bNkxDhw7V0aNHNX36dEly7olw/fa7ceNGtWrVSm3btr2oMSckJMjf3185OTlq2bKlpHOXBo4dO6bIyEhJ524WjYqKOm87vr6+Sk9P15/+9CfVr19fKSkp+uqrrzRr1izdfPPNTltV9fXXX2vMmDG65557tGPHDi1dulSPPfbYeb8YcOTIkUpLS9Mjjzyiu+++W4cOHdK0adMUHx+vpKQk+fj4aNSoURo/frwaNWqkxMREvffee9q5c6dHYExKStLixYu1dOlS/epXv9KKFSt05MgR1a1bV9K5s0j9+vVTZmam/Pz8FBMTo/fee0+7du3SU089JencfVW7du3Sli1bFBcXd0HHq7v169fr2WefVa9evZSVlaUPP/zQuYm6fv36iouL06JFi9SsWTP5+vpq9uzZHvdueePn56fhw4fr+eefV4MGDdSlSxdt3bpV77//vvMarUy7du3k6+ur559/Xv/7v/+rY8eOadGiRfrhhx+cfdShQwcZY5Senq6hQ4fK399fS5YsUb169XT99derYcOGGjhwoKZMmaLjx4+rQ4cO+vLLLzV9+nSlpKQoODhYiYmJ6tSpkx5//HH9/ve/V506dZSRkSF/f/9yY8rJyVFwcLDHGWSgqgg4OK9BgwYpIiJCy5Yt08SJE3XixAk1aNDA+fK/Fi1aSDr3G/Ly5cs1depUPf300yoqKtK1116ruXPnOp9WSk1NVWlpqV577TVlZGSocePGevDBB5Wenl6l9WW98MILmjFjhl555RXl5+erdevWev75551AVV1c/cyfP19HjhxReHi4HnvsMT388MNVbqNBgwZatGiRJk6cqFGjRiksLEx//OMf9eijjzpvssHBwRo6dKiWLVumnJwcj+/0uRDBwcHq2rWrPv30U915552SpH//+98KDQ117n2oSsCR5Hx79aJFi/TGG28oLCxMDz30kPNJowvRt29fFRUVKT09XaGhoXriiSfUv3//8z7H9f05c+bMUVpamkJCQnTbbbdpzJgxToBxfbpt/vz5Wr58ubp27arU1FQtWLDAaSc1NVV5eXmaPn26/Pz8dMcdd2j48OEe30HzxBNPqEGDBlq+fLmOHTuma6+9VgsWLFBsbKykc6+FMWPGaMiQIVqyZMkFH68uQ4YM0a5du5SWlqaWLVtq+vTpHjdST548WU8//bR+//vfKzQ0VMOGDdPGjRsrre/gwYMVGBioJUuW6JVXXlGrVq00bdo03XDDDZU+Vzp3xu7Pf/6zZs+erWHDhik0NFRJSUm655579Oyzzyo3N1dNmjTRwoUL9eKLL2rs2LEqLi5Whw4dtHjxYuf+n8cff1wNGzbU66+/rpkzZyosLMyjLj4+PsrMzNRzzz2nSZMmyc/PT4MHD9aHH35Ybkyffvqpevbs6TX8AJXxMVW92xLARcvJydGZM2fUpUsXZ9k333yjm2++WXPnznW+5K+6bNmyxfmm6Qu5cbWm9OrVSz179nTOhvxSRUVFaezYsRcUjn+pjhw5oh49euj111/nm4xxUbgHB7gM9u3bp6FDh2rRokXaunWr3nvvPT3yyCNq1aqVunXrVu39XX/99UpISNBf/vKXam8buBxeffVVpaSkEG5w0bhEBVwGd955p44dO6aVK1cqIyNDdevWVWJioh5//HEFBgbWSJ9/+tOfNGDAAN133318CgU/K4cPH9bf//53rVq16koPBT9jXKICAADW4RIVAACwDgEHAABYh4ADAACsQ8ABAADWIeAAAADr/H8RM5t/JB8JuwAAAABJRU5ErkJggg==\n", 591 | "text/plain": [ 592 | "
" 593 | ] 594 | }, 595 | "metadata": {}, 596 | "output_type": "display_data" 597 | } 598 | ], 599 | "source": [ 600 | "session_lengths = df.groupby(\"CustomerID\").count()['InvoiceNo'].values\n", 601 | "\n", 602 | "fig = plt.figure(figsize=(8,6))\n", 603 | "plt.xticks(fontsize=14)\n", 604 | "\n", 605 | "ax = sns.boxplot(x=session_lengths, color=cldr_colors[2])\n", 606 | "\n", 607 | "for patch in ax.artists:\n", 608 | " r, g, b, a = patch.get_facecolor()\n", 609 | " patch.set_facecolor((r, g, b, .7))\n", 610 | " \n", 611 | "plt.xlim(0,600)\n", 612 | "plt.xlabel(\"Session length (# of products purchased)\", fontsize=16);\n", 613 | "\n", 614 | "plt.tight_layout()\n", 615 | "plt.savefig(\"../../recommendations/docs/images/session_lengths.png\", transparent=True, dpi=150)" 616 | ] 617 | }, 618 | { 619 | "cell_type": "code", 620 | "execution_count": 57, 621 | "metadata": {}, 622 | "outputs": [ 623 | { 624 | "name": "stdout", 625 | "output_type": "stream", 626 | "text": [ 627 | "Minimum session length: \t 3\n", 628 | "Maximum session length: \t 7983\n", 629 | "Mean session length: \t \t 96.03967879074162\n", 630 | "Median session length: \t \t 44.0\n", 631 | "Total number of purchases: \t 406632\n" 632 | ] 633 | } 634 | ], 635 | "source": [ 636 | "print(\"Minimum session length: \\t\", min(session_lengths))\n", 637 | "print(\"Maximum session length: \\t\", max(session_lengths))\n", 638 | "print(\"Mean session length: \\t \\t\", np.mean(session_lengths))\n", 639 | "print(\"Median session length: \\t \\t\", np.median(session_lengths))\n", 640 | "print(\"Total number of purchases: \\t\", np.sum(session_lengths))" 641 | ] 642 | }, 643 | { 644 | "cell_type": "markdown", 645 | "metadata": {}, 646 | "source": [ 647 | "## Misc " 648 | ] 649 | }, 650 | { 651 | "cell_type": "code", 652 | "execution_count": 63, 653 | "metadata": {}, 654 | "outputs": [ 655 | { 656 | "data": { 657 | "text/plain": [ 658 | "Timedelta('69 days 23:11:00')" 659 | ] 660 | }, 661 | "execution_count": 63, 662 | "metadata": {}, 663 | "output_type": "execute_result" 664 | } 665 | ], 666 | "source": [ 667 | "customer_grps.get_group(custIDs[0])['InvoiceDate'].diff().max()" 668 | ] 669 | }, 670 | { 671 | "cell_type": "code", 672 | "execution_count": 66, 673 | "metadata": {}, 674 | "outputs": [ 675 | { 676 | "data": { 677 | "text/plain": [ 678 | "312" 679 | ] 680 | }, 681 | "execution_count": 66, 682 | "metadata": {}, 683 | "output_type": "execute_result" 684 | } 685 | ], 686 | "source": [ 687 | "len(customer_grps.get_group(custIDs[0])['StockCode'])" 688 | ] 689 | }, 690 | { 691 | "cell_type": "code", 692 | "execution_count": 66, 693 | "metadata": {}, 694 | "outputs": [], 695 | "source": [ 696 | "customer_grps = df.groupby(\"CustomerID\")\n", 697 | "max_time_diff = []\n", 698 | "\n", 699 | "for custID, history in customer_grps:\n", 700 | " max_time_diff.append(history['InvoiceDate'].diff().max().days)" 701 | ] 702 | }, 703 | { 704 | "cell_type": "code", 705 | "execution_count": 72, 706 | "metadata": {}, 707 | "outputs": [ 708 | { 709 | "data": { 710 | "text/plain": [ 711 | "" 712 | ] 713 | }, 714 | "execution_count": 72, 715 | "metadata": {}, 716 | "output_type": "execute_result" 717 | }, 718 | { 719 | "data": { 720 | "image/png": "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\n", 721 | "text/plain": [ 722 | "
" 723 | ] 724 | }, 725 | "metadata": {}, 726 | "output_type": "display_data" 727 | } 728 | ], 729 | "source": [ 730 | "max_time_diff\n", 731 | "\n", 732 | "plt.scatter(np.arange(len(max_time_diff)), sorted(max_time_diff))" 733 | ] 734 | }, 735 | { 736 | "cell_type": "code", 737 | "execution_count": 74, 738 | "metadata": {}, 739 | "outputs": [ 740 | { 741 | "data": { 742 | "text/html": [ 743 | "
\n", 744 | "\n", 757 | "\n", 758 | " \n", 759 | " \n", 760 | " \n", 761 | " \n", 762 | " \n", 763 | " \n", 764 | " \n", 765 | " \n", 766 | " \n", 767 | " \n", 768 | " \n", 769 | " \n", 770 | " \n", 771 | " \n", 772 | " \n", 773 | " \n", 774 | " \n", 775 | " \n", 776 | " \n", 777 | " \n", 778 | " \n", 779 | " \n", 780 | " \n", 781 | " \n", 782 | " \n", 783 | " \n", 784 | " \n", 785 | " \n", 786 | " \n", 787 | " \n", 788 | " \n", 789 | " \n", 790 | " \n", 791 | " \n", 792 | " \n", 793 | " \n", 794 | " \n", 795 | " \n", 796 | " \n", 797 | " \n", 798 | " \n", 799 | " \n", 800 | " \n", 801 | " \n", 802 | " \n", 803 | " \n", 804 | " \n", 805 | " \n", 806 | " \n", 807 | " \n", 808 | " \n", 809 | " \n", 810 | " \n", 811 | " \n", 812 | " \n", 813 | " \n", 814 | " \n", 815 | " \n", 816 | " \n", 817 | " \n", 818 | " \n", 819 | " \n", 820 | " \n", 821 | " \n", 822 | " \n", 823 | " \n", 824 | " \n", 825 | " \n", 826 | " \n", 827 | " \n", 828 | " \n", 829 | " \n", 830 | " \n", 831 | " \n", 832 | " \n", 833 | " \n", 834 | "
InvoiceNoDescriptionInvoiceDate
10652537626BLACK CANDELABRA T-LIGHT HOLDER2010-12-07 14:57:00
10653537626AIRLINE BAG VINTAGE JET SET BROWN2010-12-07 14:57:00
10654537626COLOUR GLASS. STAR T-LIGHT HOLDER2010-12-07 14:57:00
10655537626MINI PAINT SET VINTAGE2010-12-07 14:57:00
10656537626CLEAR DRAWER KNOB ACRYLIC EDWARDIAN2010-12-07 14:57:00
............
403109581180WOODLAND CHARLOTTE BAG2011-12-07 15:52:00
403110581180PINK GOOSE FEATHER TREE 60CM2011-12-07 15:52:00
403111581180CHRISTMAS TABLE SILVER CANDLE SPIKE2011-12-07 15:52:00
403112581180MINI PLAYING CARDS SPACEBOY2011-12-07 15:52:00
403113581180MINI PLAYING CARDS DOLLY GIRL2011-12-07 15:52:00
\n", 835 | "

182 rows × 3 columns

\n", 836 | "
" 837 | ], 838 | "text/plain": [ 839 | " InvoiceNo Description InvoiceDate\n", 840 | "10652 537626 BLACK CANDELABRA T-LIGHT HOLDER 2010-12-07 14:57:00\n", 841 | "10653 537626 AIRLINE BAG VINTAGE JET SET BROWN 2010-12-07 14:57:00\n", 842 | "10654 537626 COLOUR GLASS. STAR T-LIGHT HOLDER 2010-12-07 14:57:00\n", 843 | "10655 537626 MINI PAINT SET VINTAGE 2010-12-07 14:57:00\n", 844 | "10656 537626 CLEAR DRAWER KNOB ACRYLIC EDWARDIAN 2010-12-07 14:57:00\n", 845 | "... ... ... ...\n", 846 | "403109 581180 WOODLAND CHARLOTTE BAG 2011-12-07 15:52:00\n", 847 | "403110 581180 PINK GOOSE FEATHER TREE 60CM 2011-12-07 15:52:00\n", 848 | "403111 581180 CHRISTMAS TABLE SILVER CANDLE SPIKE 2011-12-07 15:52:00\n", 849 | "403112 581180 MINI PLAYING CARDS SPACEBOY 2011-12-07 15:52:00\n", 850 | "403113 581180 MINI PLAYING CARDS DOLLY GIRL 2011-12-07 15:52:00\n", 851 | "\n", 852 | "[182 rows x 3 columns]" 853 | ] 854 | }, 855 | "execution_count": 74, 856 | "metadata": {}, 857 | "output_type": "execute_result" 858 | } 859 | ], 860 | "source": [ 861 | "test_grp = customer_grps.get_group(12347.0)\n", 862 | "\n", 863 | "test_grp[['InvoiceNo','Description', 'InvoiceDate']]" 864 | ] 865 | }, 866 | { 867 | "cell_type": "code", 868 | "execution_count": 77, 869 | "metadata": { 870 | "scrolled": false 871 | }, 872 | "outputs": [ 873 | { 874 | "name": "stdout", 875 | "output_type": "stream", 876 | "text": [ 877 | "2010-12-07 14:57:00\n", 878 | "10652 BLACK CANDELABRA T-LIGHT HOLDER\n", 879 | "10653 AIRLINE BAG VINTAGE JET SET BROWN\n", 880 | "10654 COLOUR GLASS. STAR T-LIGHT HOLDER\n", 881 | "10655 MINI PAINT SET VINTAGE \n", 882 | "10656 CLEAR DRAWER KNOB ACRYLIC EDWARDIAN\n", 883 | "10657 PINK DRAWER KNOB ACRYLIC EDWARDIAN\n", 884 | "10658 GREEN DRAWER KNOB ACRYLIC EDWARDIAN\n", 885 | "10659 RED DRAWER KNOB ACRYLIC EDWARDIAN\n", 886 | "10660 PURPLE DRAWERKNOB ACRYLIC EDWARDIAN\n", 887 | "10661 BLUE DRAWER KNOB ACRYLIC EDWARDIAN\n", 888 | "10662 ALARM CLOCK BAKELIKE CHOCOLATE\n", 889 | "10663 ALARM CLOCK BAKELIKE GREEN\n", 890 | "10664 ALARM CLOCK BAKELIKE RED \n", 891 | "10665 ALARM CLOCK BAKELIKE PINK\n", 892 | "10666 ALARM CLOCK BAKELIKE ORANGE\n", 893 | "10667 FOUR HOOK WHITE LOVEBIRDS\n", 894 | "10668 BLACK GRAND BAROQUE PHOTO FRAME\n", 895 | "10669 BATHROOM METAL SIGN \n", 896 | "10670 LARGE HEART MEASURING SPOONS\n", 897 | "10671 BOX OF 6 ASSORTED COLOUR TEASPOONS\n", 898 | "10672 BLUE 3 PIECE POLKADOT CUTLERY SET\n", 899 | "10673 RED 3 PIECE RETROSPOT CUTLERY SET\n", 900 | "10674 PINK 3 PIECE POLKADOT CUTLERY SET\n", 901 | "10675 EMERGENCY FIRST AID TIN \n", 902 | "10676 SET OF 2 TINS VINTAGE BATHROOM \n", 903 | "10677 SET/3 DECOUPAGE STACKING TINS\n", 904 | "10678 BOOM BOX SPEAKER BOYS\n", 905 | "10679 RED TOADSTOOL LED NIGHT LIGHT\n", 906 | "10680 3D DOG PICTURE PLAYING CARDS\n", 907 | "10681 BLACK EAR MUFF HEADPHONES\n", 908 | "10682 CAMOUFLAGE EAR MUFF HEADPHONES\n", 909 | "Name: Description, dtype: object\n", 910 | "2011-01-26 14:30:00\n", 911 | "44582 PINK NEW BAROQUECANDLESTICK CANDLE\n", 912 | "44583 BLUE NEW BAROQUE CANDLESTICK CANDLE\n", 913 | "44584 BLACK CANDELABRA T-LIGHT HOLDER\n", 914 | "44585 WOODLAND CHARLOTTE BAG\n", 915 | "44586 AIRLINE BAG VINTAGE JET SET BROWN\n", 916 | "44587 AIRLINE BAG VINTAGE JET SET WHITE\n", 917 | "44588 SANDWICH BATH SPONGE\n", 918 | "44589 ALARM CLOCK BAKELIKE CHOCOLATE\n", 919 | "44590 ALARM CLOCK BAKELIKE GREEN\n", 920 | "44591 ALARM CLOCK BAKELIKE RED \n", 921 | "44592 ALARM CLOCK BAKELIKE PINK\n", 922 | "44593 ALARM CLOCK BAKELIKE ORANGE\n", 923 | "44594 SMALL HEART MEASURING SPOONS\n", 924 | "44595 72 SWEETHEART FAIRY CAKE CASES\n", 925 | "44596 60 TEATIME FAIRY CAKE CASES\n", 926 | "44597 PACK OF 60 MUSHROOM CAKE CASES\n", 927 | "44598 PACK OF 60 SPACEBOY CAKE CASES\n", 928 | "44599 TEA TIME OVEN GLOVE\n", 929 | "44600 RED RETROSPOT OVEN GLOVE \n", 930 | "44601 RED RETROSPOT OVEN GLOVE DOUBLE\n", 931 | "44602 SET/2 RED RETROSPOT TEA TOWELS \n", 932 | "44603 REGENCY CAKESTAND 3 TIER\n", 933 | "44604 BOX OF 6 ASSORTED COLOUR TEASPOONS\n", 934 | "44605 MINI LADLE LOVE HEART RED \n", 935 | "44606 CHOCOLATE CALCULATOR\n", 936 | "44607 TOOTHPASTE TUBE PEN\n", 937 | "44608 SET OF 2 TINS VINTAGE BATHROOM \n", 938 | "44609 RED TOADSTOOL LED NIGHT LIGHT\n", 939 | "44610 3D DOG PICTURE PLAYING CARDS\n", 940 | "Name: Description, dtype: object\n", 941 | "2011-04-07 10:43:00\n", 942 | "101930 AIRLINE BAG VINTAGE JET SET WHITE\n", 943 | "101931 AIRLINE BAG VINTAGE JET SET RED\n", 944 | "101932 AIRLINE BAG VINTAGE TOKYO 78\n", 945 | "101933 AIRLINE BAG VINTAGE JET SET BROWN\n", 946 | "101934 RED RETROSPOT PURSE \n", 947 | "101935 ICE CREAM SUNDAE LIP GLOSS\n", 948 | "101936 VINTAGE HEADS AND TAILS CARD GAME \n", 949 | "101937 HOLIDAY FUN LUDO\n", 950 | "101938 TREASURE ISLAND BOOK BOX\n", 951 | "101939 WATERING CAN PINK BUNNY\n", 952 | "101940 RED DRAWER KNOB ACRYLIC EDWARDIAN\n", 953 | "101941 LARGE HEART MEASURING SPOONS\n", 954 | "101942 SMALL HEART MEASURING SPOONS\n", 955 | "101943 PACK OF 60 DINOSAUR CAKE CASES\n", 956 | "101944 RED RETROSPOT OVEN GLOVE DOUBLE\n", 957 | "101945 REGENCY CAKESTAND 3 TIER\n", 958 | "101946 ROSES REGENCY TEACUP AND SAUCER \n", 959 | "101947 RED TOADSTOOL LED NIGHT LIGHT\n", 960 | "101948 MINI PAINT SET VINTAGE \n", 961 | "101949 3D SHEET OF DOG STICKERS\n", 962 | "101950 3D SHEET OF CAT STICKERS\n", 963 | "101951 SMALL FOLDING SCISSOR(POINTED EDGE)\n", 964 | "101952 GIFT BAG PSYCHEDELIC APPLES\n", 965 | "101953 SET OF 2 TINS VINTAGE BATHROOM \n", 966 | "Name: Description, dtype: object\n", 967 | "2011-06-09 13:01:00\n", 968 | "157619 RABBIT NIGHT LIGHT\n", 969 | "157620 REGENCY TEA STRAINER\n", 970 | "157621 REGENCY TEA PLATE GREEN \n", 971 | "157622 REGENCY TEA PLATE PINK\n", 972 | "157623 REGENCY TEA PLATE ROSES \n", 973 | "157624 REGENCY TEAPOT ROSES \n", 974 | "157625 REGENCY SUGAR BOWL GREEN\n", 975 | "157626 REGENCY MILK JUG PINK \n", 976 | "157627 AIRLINE BAG VINTAGE TOKYO 78\n", 977 | "157628 AIRLINE BAG VINTAGE JET SET BROWN\n", 978 | "157629 VICTORIAN SEWING KIT\n", 979 | "157630 NAMASTE SWAGAT INCENSE\n", 980 | "157631 TRIPLE HOOK ANTIQUE IVORY ROSE\n", 981 | "157632 SMALL HEART MEASURING SPOONS\n", 982 | "157633 3D DOG PICTURE PLAYING CARDS\n", 983 | "157634 FEATHER PEN,COAL BLACK\n", 984 | "157635 ALARM CLOCK BAKELIKE RED \n", 985 | "157636 ALARM CLOCK BAKELIKE CHOCOLATE\n", 986 | "Name: Description, dtype: object\n", 987 | "2011-08-02 08:48:00\n", 988 | "205271 SET OF 60 VINTAGE LEAF CAKE CASES \n", 989 | "205272 SET 40 HEART SHAPE PETIT FOUR CASES\n", 990 | "205273 AIRLINE BAG VINTAGE JET SET BROWN\n", 991 | "205274 AIRLINE BAG VINTAGE JET SET RED\n", 992 | "205275 AIRLINE BAG VINTAGE JET SET WHITE\n", 993 | "205276 AIRLINE BAG VINTAGE TOKYO 78\n", 994 | "205277 AIRLINE BAG VINTAGE WORLD CHAMPION \n", 995 | "205278 WOODLAND DESIGN COTTON TOTE BAG\n", 996 | "205279 WOODLAND CHARLOTTE BAG\n", 997 | "205280 ALARM CLOCK BAKELIKE RED \n", 998 | "205281 TRIPLE HOOK ANTIQUE IVORY ROSE\n", 999 | "205282 SINGLE ANTIQUE ROSE HOOK IVORY\n", 1000 | "205283 TEA TIME OVEN GLOVE\n", 1001 | "205284 72 SWEETHEART FAIRY CAKE CASES\n", 1002 | "205285 60 TEATIME FAIRY CAKE CASES\n", 1003 | "205286 PACK OF 60 DINOSAUR CAKE CASES\n", 1004 | "205287 REGENCY CAKESTAND 3 TIER\n", 1005 | "205288 REGENCY MILK JUG PINK \n", 1006 | "205289 3D DOG PICTURE PLAYING CARDS\n", 1007 | "205290 REVOLVER WOODEN RULER \n", 1008 | "205291 VINTAGE HEADS AND TAILS CARD GAME \n", 1009 | "205292 RED REFECTORY CLOCK \n", 1010 | "Name: Description, dtype: object\n", 1011 | "2011-10-31 12:25:00\n", 1012 | "322193 MINI LIGHTS WOODLAND MUSHROOMS\n", 1013 | "322194 PINK GOOSE FEATHER TREE 60CM\n", 1014 | "322195 MADRAS NOTEBOOK MEDIUM\n", 1015 | "322196 AIRLINE BAG VINTAGE WORLD CHAMPION \n", 1016 | "322197 AIRLINE BAG VINTAGE JET SET BROWN\n", 1017 | "322198 AIRLINE BAG VINTAGE TOKYO 78\n", 1018 | "322199 AIRLINE BAG VINTAGE JET SET RED\n", 1019 | "322200 BIRDCAGE DECORATION TEALIGHT HOLDER\n", 1020 | "322201 CHRISTMAS METAL TAGS ASSORTED \n", 1021 | "322202 REGENCY CAKESTAND 3 TIER\n", 1022 | "322203 REGENCY TEAPOT ROSES \n", 1023 | "322204 TEA TIME DES TEA COSY\n", 1024 | "322205 TEA TIME KITCHEN APRON\n", 1025 | "322206 TEA TIME OVEN GLOVE\n", 1026 | "322207 PINK REGENCY TEACUP AND SAUCER\n", 1027 | "322208 GREEN REGENCY TEACUP AND SAUCER\n", 1028 | "322209 3D DOG PICTURE PLAYING CARDS\n", 1029 | "322210 RABBIT NIGHT LIGHT\n", 1030 | "322211 RED TOADSTOOL LED NIGHT LIGHT\n", 1031 | "322212 TREASURE ISLAND BOOK BOX\n", 1032 | "322213 VINTAGE HEADS AND TAILS CARD GAME \n", 1033 | "322214 MINI PLAYING CARDS DOLLY GIRL \n", 1034 | "322215 MINI PLAYING CARDS SPACEBOY \n", 1035 | "322216 PLAYING CARDS KEEP CALM & CARRY ON\n", 1036 | "322217 REVOLVER WOODEN RULER \n", 1037 | "322218 WOODEN SCHOOL COLOURING SET\n", 1038 | "322219 MINI PAINT SET VINTAGE \n", 1039 | "322220 TRADITIONAL KNITTING NANCY\n", 1040 | "322221 TRIPLE HOOK ANTIQUE IVORY ROSE\n", 1041 | "322222 PANTRY HOOK SPATULA\n", 1042 | "322223 PANTRY HOOK BALLOON WHISK \n", 1043 | "322224 PANTRY HOOK TEA STRAINER \n", 1044 | "322225 ROSES REGENCY TEACUP AND SAUCER \n", 1045 | "322226 ALARM CLOCK BAKELIKE CHOCOLATE\n", 1046 | "322227 ALARM CLOCK BAKELIKE PINK\n", 1047 | "322228 ALARM CLOCK BAKELIKE GREEN\n", 1048 | "322229 ALARM CLOCK BAKELIKE RED \n", 1049 | "322230 PACK OF 60 MUSHROOM CAKE CASES\n", 1050 | "322231 PACK OF 60 SPACEBOY CAKE CASES\n", 1051 | "322232 SET OF 60 VINTAGE LEAF CAKE CASES \n", 1052 | "322233 60 TEATIME FAIRY CAKE CASES\n", 1053 | "322234 72 SWEETHEART FAIRY CAKE CASES\n", 1054 | "322235 SMALL HEART MEASURING SPOONS\n", 1055 | "322236 LARGE HEART MEASURING SPOONS\n", 1056 | "322237 WOODLAND CHARLOTTE BAG\n", 1057 | "322238 REGENCY TEA STRAINER\n", 1058 | "322239 FOOD CONTAINER SET 3 LOVE HEART \n", 1059 | "Name: Description, dtype: object\n", 1060 | "2011-12-07 15:52:00\n", 1061 | "403103 CLASSIC CHROME BICYCLE BELL \n", 1062 | "403104 BICYCLE PUNCTURE REPAIR KIT \n", 1063 | "403105 BOOM BOX SPEAKER BOYS\n", 1064 | "403106 PINK NEW BAROQUECANDLESTICK CANDLE\n", 1065 | "403107 RED TOADSTOOL LED NIGHT LIGHT\n", 1066 | "403108 RABBIT NIGHT LIGHT\n", 1067 | "403109 WOODLAND CHARLOTTE BAG\n", 1068 | "403110 PINK GOOSE FEATHER TREE 60CM\n", 1069 | "403111 CHRISTMAS TABLE SILVER CANDLE SPIKE\n", 1070 | "403112 MINI PLAYING CARDS SPACEBOY \n", 1071 | "403113 MINI PLAYING CARDS DOLLY GIRL \n", 1072 | "Name: Description, dtype: object\n" 1073 | ] 1074 | } 1075 | ], 1076 | "source": [ 1077 | "for invoice_date, grp in test_grp.groupby('InvoiceDate'):\n", 1078 | " print(invoice_date)\n", 1079 | " print(grp['Description'])" 1080 | ] 1081 | }, 1082 | { 1083 | "cell_type": "code", 1084 | "execution_count": null, 1085 | "metadata": {}, 1086 | "outputs": [], 1087 | "source": [] 1088 | } 1089 | ], 1090 | "metadata": { 1091 | "kernelspec": { 1092 | "display_name": "Python 3", 1093 | "language": "python", 1094 | "name": "python3" 1095 | }, 1096 | "language_info": { 1097 | "codemirror_mode": { 1098 | "name": "ipython", 1099 | "version": 3 1100 | }, 1101 | "file_extension": ".py", 1102 | "mimetype": "text/x-python", 1103 | "name": "python", 1104 | "nbconvert_exporter": "python", 1105 | "pygments_lexer": "ipython3", 1106 | "version": "3.8.8" 1107 | } 1108 | }, 1109 | "nbformat": 4, 1110 | "nbformat_minor": 4 1111 | } 1112 | -------------------------------------------------------------------------------- /recsys/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastforwardlabs/session_based_recommenders/c438dd1334fcefc6bedea69b0cd67f779a5de5d3/recsys/__init__.py -------------------------------------------------------------------------------- /recsys/data.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pandas as pd 3 | import numpy as np 4 | from numpy.random import default_rng 5 | 6 | from recsys.utils import pickle_load, pickle_save, absolute_filename, create_path 7 | 8 | rng = default_rng(123) 9 | 10 | RECSYS15_PATH = "data/recsys15/" 11 | RECSYS15_FILENAME = "yoochoose-clicks.dat" 12 | 13 | ECOMM_PATH = "data/ecomm/" 14 | ECOMM_FILENAME = "OnlineRetail.csv" 15 | 16 | AOTM_PATH = "data/aotm/" 17 | AOTM_FILENAME = "aotm_list_ids.txt" 18 | AOTM_NUMPYFILENAME = "aotm_sessions.pkl" 19 | 20 | 21 | def load_recsys15(filename=None): 22 | """ 23 | Checks to see if the processed recsys15 session sequence file exists 24 | If True: loads and returns the session sequences 25 | If False: creates and returns the session sequences constructed from the original data file 26 | """ 27 | original_filename = absolute_filename(RECSYS15_PATH, RECSYS15_FILENAME) 28 | if filename is None: 29 | processed_filename = original_filename.replace(".dat", "_sessions.pkl") 30 | if os.path.exists(processed_filename): 31 | return pickle_load(processed_filename) 32 | else: 33 | if os.path.exists(absolute_filename(filename)): 34 | return pickle_load(absolute_filename(filename)) 35 | 36 | df = load_original_recsys15(original_filename) 37 | session_sequences = preprocess_recsys15(df) 38 | return session_sequences 39 | 40 | 41 | def load_original_recsys15(pathname=RECSYS15_PATH): 42 | """ 43 | Reads in the original RecSys15 Challenge "clicks" data file and returns as a Pandas DF 44 | """ 45 | df = pd.read_csv( 46 | absolute_filename(pathname, RECSYS15_FILENAME), 47 | names=["sessionID", "timestamp", "itemID", "category"], 48 | date_parser=["timestamp"], 49 | dtype={"category": str, "itemID": str, "sessionID": str}, 50 | ) 51 | return df 52 | 53 | 54 | def preprocess_recsys15(df, min_session_count=3): 55 | """ 56 | Given the recsys15 data in pandas df format, clean and sample to only those 57 | sessions that contain at least min_session_count number of interactions 58 | """ 59 | session_counts = df.groupby(["sessionID"]).count() 60 | df = df[ 61 | df["sessionID"].isin( 62 | session_counts[session_counts["itemID"] >= min_session_count].index 63 | ) 64 | ].reset_index(drop=True) 65 | 66 | # TODO: track preprocessed version by appending the filename with min_session_count 67 | filename = absolute_filename( 68 | RECSYS15_PATH, RECSYS15_FILENAME.replace(".dat", f"_sessions.pkl") 69 | ) 70 | sessions = construct_session_sequences( 71 | df, "sessionID", "itemID", save_filename=filename 72 | ) 73 | return sessions 74 | 75 | 76 | def load_ecomm(filename=None): 77 | """ 78 | Checks to see if the processed Online Retail ecommerce session sequence file exists 79 | If True: loads and returns the session sequences 80 | If False: creates and returns the session sequences constructed from the original data file 81 | """ 82 | original_filename = absolute_filename(ECOMM_PATH, ECOMM_FILENAME) 83 | if filename is None: 84 | processed_filename = original_filename.replace(".csv", "_sessions.pkl") 85 | if os.path.exists(processed_filename): 86 | return pickle_load(processed_filename) 87 | else: 88 | if os.path.exists(absolute_filename(filename)): 89 | return pickle_load(absolute_filename(filename)) 90 | 91 | df = load_original_ecomm(original_filename) 92 | session_sequences = preprocess_ecomm(df) 93 | return session_sequences 94 | 95 | 96 | def load_original_ecomm(pathname=ECOMM_PATH): 97 | df = pd.read_csv( 98 | absolute_filename(pathname, ECOMM_FILENAME), 99 | encoding="ISO-8859-1", 100 | parse_dates=["InvoiceDate"], 101 | ) 102 | return df 103 | 104 | 105 | def preprocess_ecomm(df, min_session_count=3): 106 | df.dropna(inplace=True) 107 | item_counts = df.groupby(["CustomerID"]).count()["StockCode"] 108 | df = df[ 109 | df["CustomerID"].isin(item_counts[item_counts >= min_session_count].index) 110 | ].reset_index(drop=True) 111 | 112 | # TODO: track preprocessed version by appending the filename with min_session_count 113 | filename = absolute_filename( 114 | ECOMM_PATH, ECOMM_FILENAME.replace(".csv", "_sessions.pkl") 115 | ) 116 | sessions = construct_session_sequences( 117 | df, "CustomerID", "StockCode", save_filename=filename 118 | ) 119 | return sessions 120 | 121 | 122 | def load_aotm(filename=None): 123 | """ 124 | Checks to see if the processed aotm session sequence file exists 125 | If True: loads and returns the session sequences 126 | If False: creates and returns the session sequences constructed from the original data file 127 | """ 128 | processed_filename = absolute_filename(AOTM_PATH, AOTM_NUMPYFILENAME) 129 | 130 | if os.path.exists(processed_filename): 131 | return pickle_load(processed_filename) 132 | 133 | original_filename = absolute_filename(AOTM_PATH, AOTM_FILENAME) 134 | df = load_original_aotm(original_filename) 135 | session_sequences = preprocess_aotm(df, save_path=AOTM_PATH) 136 | # session_sequences = construct_session_sequences(df, save_path=processed_filename) 137 | return session_sequences 138 | 139 | 140 | def load_original_aotm(pathname=AOTM_PATH): 141 | """ 142 | Reads in the original AOTM file with all 29,164 playlists in numerical format. 143 | Each line defines a playlist in the form #num# artnum: songnum artnum: songnum ... where num is the playlist index 144 | Returns a Pandas DF 145 | """ 146 | df = pd.read_csv( 147 | absolute_filename(pathname, AOTM_FILENAME), 148 | delimiter="# ", 149 | header=None, 150 | names=["list_id", "artists_tracks"], 151 | dtype={"category": int, "artists_tracks": str}, 152 | engine="python", 153 | ) 154 | # some sessions have missing entries... 155 | df.dropna(inplace=True) 156 | return df 157 | 158 | 159 | def preprocess_aotm(df, min_session_count=3, save_path=None): 160 | """ 161 | Given the aotm data in pandas df format, clean and sample to only those 162 | sessions that contain at least min_session_count number of interactions 163 | """ 164 | 165 | # separate out the artists and tracks within the sessions and use only the tracks for word2vec modeling 166 | artists_tracks = df["artists_tracks"].tolist() 167 | artists_tracks_tokens = [a.split() for a in artists_tracks] 168 | track_tokens = [ 169 | [x for x in token if not x.endswith(":")] for token in artists_tracks_tokens 170 | ] 171 | 172 | # exclude tracks with only one entry, remove if there are tokens with len < min_session_count 173 | track_tokens = [token for token in track_tokens if len(token) >= min_session_count] 174 | 175 | if save_path: 176 | create_path(save_path) 177 | pickle_save( 178 | track_tokens, filename=absolute_filename(save_path, AOTM_NUMPYFILENAME) 179 | ) 180 | return track_tokens 181 | 182 | 183 | def construct_session_sequences(df, sessionID, itemID, save_filename): 184 | """ 185 | Given a dataset in pandas df format, construct a list of lists where each sublist 186 | represents the interactions relevant to a specific session, for each sessionID. 187 | These sublists are composed of a series of itemIDs (str) and are the core training 188 | data used in the Word2Vec algorithm. 189 | 190 | This is performed by first grouping over the SessionID column, then casting to list 191 | each group's series of values in the ItemID column. 192 | 193 | INPUTS 194 | ------------ 195 | df: pandas dataframe 196 | sessionID: str column name in the df that represents invididual sessions 197 | itemID: str column name in the df that represents the items within a session 198 | save_filename: str output filename 199 | 200 | Example: 201 | Given a df that looks like 202 | 203 | SessionID | ItemID 204 | ---------------------- 205 | 1 | 111 206 | 1 | 123 207 | 1 | 345 208 | 2 | 045 209 | 2 | 334 210 | 2 | 342 211 | 2 | 8970 212 | 2 | 345 213 | 214 | Retrun a list of lists like this: 215 | 216 | sessions = [ 217 | ['111', '123', '345'], 218 | ['045', '334', '342', '8970', '345'], 219 | ] 220 | """ 221 | grp_by_session = df.groupby([sessionID]) 222 | 223 | session_sequences = [] 224 | for name, group in grp_by_session: 225 | session_sequences.append(list(group[itemID].values)) 226 | 227 | filename = absolute_filename(save_filename) 228 | create_path(filename) 229 | pickle_save(session_sequences, filename=save_filename) 230 | return session_sequences 231 | 232 | 233 | def train_test_split(session_sequences, test_size: int = 10000, rng=rng): 234 | """ 235 | Next Event Prediction (NEP) does not necessarily follow the traditional train/test split. 236 | 237 | Instead training is perform on the first n-1 items in a session sequence of n items. 238 | The test set is constructed of (n-1, n) "query" pairs where the n-1 item is used to generate 239 | recommendation predictions and it is checked whether the nth item is included in those recommendations. 240 | 241 | Example: 242 | Given a session sequence ['045', '334', '342', '8970', '128'] 243 | Training is done on ['045', '334', '342', '8970'] 244 | Testing (and validation) is done on ['8970', '128'] 245 | 246 | Test and Validation sets are constructed to be disjoint. 247 | """ 248 | #np.random.seed(123) 249 | #rng = np.random.default_rng(123) 250 | 251 | ### Construct training set 252 | # use (1 st, ..., n-1 th) items from each session sequence to form the train set (drop last item) 253 | train = [sess[:-1] for sess in session_sequences] 254 | 255 | if test_size > len(train): 256 | print( 257 | f"Test set cannot be larger than train set. Train set contains {len(train)} sessions." 258 | ) 259 | return 260 | 261 | ### Construct test and validation sets 262 | # sub-sample 10k sessions, and use (n-1 th, n th) pairs of items from session_squences to form the 263 | # disjoint validaton and test sets 264 | test_validation = [sess[-2:] for sess in session_sequences] 265 | # TODO: set numpy random seed! NM: added it at the top 266 | index = rng.choice(range(len(test_validation)), test_size * 2, replace=False) 267 | test = np.array(test_validation)[index[:test_size]].tolist() 268 | validation = np.array(test_validation)[index[test_size:]].tolist() 269 | 270 | return train, test, validation 271 | 272 | 273 | #""" 274 | 275 | if __name__ == "__main__": 276 | # load data 277 | sessions = load_ecomm() 278 | 279 | #df = load_original_ecomm() 280 | #sessions = preprocess_ecomm(df) 281 | #print(sessions[0]) 282 | 283 | print(len(sessions)) 284 | #train, test, valid = train_test_split(sessions) 285 | 286 | train, test, valid = train_test_split(sessions, test_size=1000) 287 | #print(train[0]) 288 | print("validation set:", valid[0]) 289 | print("test set", test[0]) 290 | #""" 291 | 292 | -------------------------------------------------------------------------------- /recsys/metrics.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import numpy as np 3 | 4 | 5 | def recall_at_k(test, embeddings, k: int = 10) -> float: 6 | """ 7 | test must be a list of (query, ground truth) pairs 8 | embeddings must be a gensim.word2vec.wv thingy 9 | """ 10 | ratk_score = 0 11 | for query_item, ground_truth in test: 12 | # get the k most similar items to the query item (computes cosine similarity) 13 | neighbors = embeddings.similar_by_vector(query_item, topn=k) 14 | # clean up the list 15 | recommendations = [item for item, score in neighbors] 16 | # check if ground truth is in the recommedations 17 | if ground_truth in recommendations: 18 | ratk_score += 1 19 | ratk_score /= len(test) 20 | return ratk_score 21 | 22 | 23 | def recall_at_k_baseline(test, comatrix, k: int = 10) -> float: 24 | """ 25 | test must be a list of (query, ground truth) pairs 26 | embeddings must be a gensim.word2vec.wv thingy 27 | """ 28 | ratk_score = 0 29 | for query_item, ground_truth in test: 30 | # get the k most similar items to the query item (computes cosine similarity) 31 | try: 32 | co_occ = collections.Counter(comatrix[query_item]) 33 | items_and_counts = co_occ.most_common(k) 34 | recommendations = [item for (item, counts) in items_and_counts] 35 | if ground_truth in recommendations: 36 | ratk_score +=1 37 | except: 38 | pass 39 | ratk_score /= len(test) 40 | return ratk_score 41 | 42 | 43 | def hitratio_at_k(test, embeddings, k: int = 10) -> float: 44 | """ 45 | Implemented EXACTLY as was done in the Hyperparameters Matter paper. 46 | In the paper this metric is described as 47 | • Hit ratio at K (HR@K). It is equal to 1 if the test item appears 48 | in the list of k predicted items and 0 otherwise [13]. 49 | 50 | But this is not what they implement, where they instead divide by k. 51 | What they have actually implemented is more like Precision@k. 52 | However, Precision@k doesn't make a lot of sense in this context because 53 | there is only ONE possible correct answer in the list of generated 54 | recommendations. I don't think this is the best metric to use but 55 | I'll keep it here for posterity. 56 | 57 | test must be a list of (query, ground truth) pairs 58 | embeddings must be a gensim.word2vec.wv thingy 59 | """ 60 | hratk_score = 0 61 | for query_item, ground_truth in test: 62 | # If the query item and next item are the same, prediction is automatically correct 63 | if query_item == ground_truth: 64 | hratk_score += 1 / k 65 | else: 66 | # get the k most similar items to the query item (computes cosine similarity) 67 | neighbors = embeddings.similar_by_vector(query_item, topn=k) 68 | # clean up the list 69 | recommendations = [item for item, score in neighbors] 70 | # check if ground truth is in the recommedations 71 | if ground_truth in recommendations: 72 | hratk_score += 1 / k 73 | hratk_score /= len(test) 74 | return hratk_score*1000 75 | 76 | 77 | def mrr_at_k(test, embeddings, k: int) -> float: 78 | """ 79 | Mean Reciprocal Rank. 80 | 81 | test must be a list of (query, ground truth) pairs 82 | embeddings must be a gensim.word2vec.wv thingy 83 | """ 84 | mrratk_score = 0 85 | for query_item, ground_truth in test: 86 | # get the k most similar items to the query item (computes cosine similarity) 87 | neighbors = embeddings.similar_by_vector(query_item, topn=k) 88 | # clean up the list 89 | recommendations = [item for item, score in neighbors] 90 | # check if ground truth is in the recommedations 91 | if ground_truth in recommendations: 92 | # identify where the item is in the list 93 | rank_idx = ( 94 | np.argwhere(np.array(recommendations) == ground_truth)[0][0] + 1 95 | ) 96 | # score higher-ranked ground truth higher than lower-ranked ground truth 97 | mrratk_score += 1 / rank_idx 98 | mrratk_score /= len(test) 99 | return mrratk_score 100 | 101 | 102 | def mrr_at_k_baseline(test, comatrix, k: int = 10) -> float: 103 | """ 104 | Mean Reciprocal Rank. 105 | 106 | test must be a list of (query, ground truth) pairs 107 | embeddings must be a gensim.word2vec.wv thingy 108 | """ 109 | mrratk_score = 0 110 | for query_item, ground_truth in test: 111 | # get the k most similar items to the query item (computes cosine similarity) 112 | try: 113 | co_occ = collections.Counter(comatrix[query_item]) 114 | items_and_counts = co_occ.most_common(k) 115 | recommendations = [item for (item, counts) in items_and_counts] 116 | if ground_truth in recommendations: 117 | rank_idx = ( 118 | np.argwhere(np.array(recommendations) == ground_truth)[0][0] + 1 119 | ) 120 | mrratk_score += 1 / rank_idx 121 | except: 122 | pass 123 | mrratk_score /= len(test) 124 | return mrratk_score -------------------------------------------------------------------------------- /recsys/models.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import itertools 3 | from copy import deepcopy 4 | 5 | from gensim.models.word2vec import Word2Vec 6 | from gensim.models.callbacks import CallbackAny2Vec 7 | from ray import tune 8 | 9 | from recsys.data import ( 10 | load_recsys15, 11 | load_aotm, 12 | load_ecomm, 13 | train_test_split 14 | ) 15 | from recsys.metrics import recall_at_k, mrr_at_k 16 | from recsys.utils import absolute_filename 17 | 18 | MODEL_DIR = "output/models/" 19 | 20 | def train_w2v(train_data, params:dict, callbacks=None, model_name=None): 21 | if model_name: 22 | # Load a model for additional training. 23 | model = Word2Vec.load(model_name) 24 | else: 25 | # train model 26 | if callbacks: 27 | model = Word2Vec(callbacks=callbacks, **params) 28 | else: 29 | model = Word2Vec(**params) 30 | model.build_vocab(train_data) 31 | 32 | model.train(train_data, total_examples=model.corpus_count, epochs=model.epochs, compute_loss=True) 33 | vectors = model.wv 34 | return vectors 35 | 36 | 37 | def tune_w2v(config): 38 | # load data 39 | if config['dataset'] == 'recsys15': 40 | sessions = load_recsys15() 41 | elif config['dataset'] == 'aotm': 42 | sessions = load_aotm() 43 | elif config['dataset'] == 'ecomm': 44 | sessions = load_ecomm() 45 | else: 46 | print(f"{config['dataset']} is not a valid dataset name. Please choose from recsys15, aotm or ecomm") 47 | return 48 | 49 | train, test, valid = train_test_split(sessions, test_size=1000) 50 | ratk_logger = RecallAtKLogger(valid, k=config['k'], ray_tune=True) 51 | 52 | # remove keys from config that aren't hyperparameters of word2vec 53 | config.pop('dataset') 54 | config.pop('k') 55 | train_w2v(train, params=config, callbacks=[ratk_logger]) 56 | 57 | 58 | class RecallAtKLogger(CallbackAny2Vec): 59 | '''Report Recall@K at each epoch''' 60 | def __init__(self, validation_set, k, ray_tune=False, save_model=False): 61 | self.epoch = 0 62 | self.recall_scores = [] 63 | self.validation = validation_set 64 | self.k = k 65 | self.tune = ray_tune 66 | self.save = save_model 67 | 68 | def on_epoch_begin(self, model): 69 | if not self.tune: 70 | print(f'Epoch: {self.epoch}', end='\t') 71 | 72 | def on_epoch_end(self, model): 73 | # method 1: deepcopy the model and set the model copy's wv to None 74 | mod = deepcopy(model) 75 | mod.wv.norms = None # will cause it recalculate norms? 76 | 77 | # Every 10 epochs, save the model 78 | if self.epoch%10 == 0 and self.save: 79 | # method 2: save and reload the. model 80 | model.save(absolute_filename(f"{MODEL_DIR}w2v_{self.epoch}.model")) 81 | #mod = Word2Vec.load(f"w2v_{self.epoch}.model") 82 | 83 | ratk_score = recall_at_k(self.validation, mod.wv, self.k) 84 | 85 | if self.tune: 86 | tune.report(recall_at_k = ratk_score) 87 | else: 88 | self.recall_scores.append(ratk_score) 89 | print(f' Recall@10: {ratk_score}') 90 | self.epoch += 1 91 | 92 | 93 | class LossLogger(CallbackAny2Vec): 94 | '''Report training loss at each epoch''' 95 | def __init__(self): 96 | self.epoch = 0 97 | self.previous_loss = 0 98 | self.training_loss = [] 99 | 100 | def on_epoch_end(self, model): 101 | # the loss output by Word2Vec is more akin to a cumulative loss and increases each epoch 102 | # to get a value closer to loss per epoch, we subtract 103 | cumulative_loss = model.get_latest_training_loss() 104 | loss = cumulative_loss - self.previous_loss 105 | self.previous_loss = cumulative_loss 106 | self.training_loss.append(loss) 107 | print(f' Loss: {loss}') 108 | self.epoch += 1 109 | 110 | 111 | def association_rules_baseline(train_sessions): 112 | """ 113 | Constructs a co-occurence matrix that counts how frequently each item 114 | co-occurs with any other item in a given session. This matrix can 115 | then be used to generate a list of recommendations according to the most 116 | frequently co-occurring items for the item in question. 117 | 118 | These recommendations must be evaluated using the "_baseline" recall/mrr functions in metrics.py 119 | """ 120 | comatrix = collections.defaultdict(list) 121 | for session in train_sessions: 122 | for (x, y) in itertools.permutations(session, 2): 123 | comatrix[x].append(y) 124 | return comatrix -------------------------------------------------------------------------------- /recsys/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pickle 3 | import pathlib 4 | 5 | 6 | def pickle_save(vector, filename, overwrite=False): 7 | if os.path.exists(filename) and not overwrite: 8 | print(f"{filename} already exists! Please use overwrite flag.") 9 | else: 10 | create_path(filename) 11 | pickle.dump(vector, open(filename, "wb")) 12 | 13 | 14 | def pickle_load(filename): 15 | if os.path.exists(filename): 16 | with open(filename, "rb") as f: 17 | return pickle.load(f) 18 | else: 19 | print(f"{filename} does not exist!") 20 | 21 | 22 | def create_path(pathname: str) -> None: 23 | """Creates the directory for the given path if it doesn't already exist.""" 24 | dir = str(pathlib.Path(pathname).parent) 25 | if not os.path.exists(dir): 26 | os.makedirs(dir) 27 | 28 | 29 | def absolute_filename(*paths) -> str: 30 | """Given a path relative to this project's top-level directory, returns the 31 | full path in the OS. 32 | Args: 33 | paths: A list of folders/files. These will be joined in order with "/" 34 | or "\" depending on platform. 35 | Returns: 36 | The full absolute path in the OS. 37 | """ 38 | # First parent gets the scripts directory, and the second gets the top-level. 39 | result_path = pathlib.Path(__file__).resolve().parent.parent 40 | for path in paths: 41 | result_path /= path 42 | return str(result_path) 43 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pandas==1.1.5 2 | numpy==1.19.2 3 | scikit-learn==0.24.1 4 | gensim==3.8.3 5 | matplotlib==3.3.4 6 | black==19.10b0 7 | dataclasses==0.8 8 | ray==1.2.0 9 | aioredis<2 10 | ray[tune] 11 | -e . # installs local recsys module in "edit" mode -------------------------------------------------------------------------------- /requirements3.6.txt: -------------------------------------------------------------------------------- 1 | pandas==1.1.5 2 | numpy==1.19.2 3 | scikit-learn==0.24.1 4 | gensim==3.8.3 5 | matplotlib==3.3.4 6 | black==19.10b0 7 | -e . # installs local recsys module in "edit" mode 8 | 9 | # The following two lines will install ray for Python 3.6 10 | https://s3-us-west-2.amazonaws.com/ray-wheels/latest/ray-2.0.0.dev0-cp36-cp36m-manylinux2014_x86_64.whl 11 | ray[tune] -------------------------------------------------------------------------------- /scripts/baseline_analysis.py: -------------------------------------------------------------------------------- 1 | from recsys.data import load_ecomm, train_test_split 2 | from recsys.models import association_rules_baseline 3 | from recsys.metrics import recall_at_k_baseline, mrr_at_k_baseline 4 | 5 | # load data 6 | sessions = load_ecomm() 7 | train, test, valid = train_test_split(sessions, test_size=1000) 8 | 9 | # Construct a co-occurrence matrix containing how frequently 10 | # each item is found in the same session as any other item 11 | comatrix = association_rules_baseline(train) 12 | 13 | # Recommendations are generated as the top K most frequently co-occurring items 14 | # Compute metrics on these recommendations for each (query item, ground truth item) 15 | # pair in the test set 16 | recall_at_10 = recall_at_k_baseline(test, comatrix, k=10) 17 | mrr_at_10 = mrr_at_k_baseline(test, comatrix, k=10) 18 | 19 | print("Recall@10:", recall_at_10) 20 | print("MRR@10:", mrr_at_10) -------------------------------------------------------------------------------- /scripts/setup_ray_cluster.py: -------------------------------------------------------------------------------- 1 | import os 2 | import argparse 3 | import cdsw 4 | import ray 5 | import time 6 | 7 | 8 | RAY_DASHBOARD_PORT = int(os.getenv("CDSW_READONLY_PORT")) 9 | 10 | WORKERS = 5 11 | CPUS = 1 12 | MEMORY = 2 13 | 14 | ### RUN THE FOLLOWING LINES TO INITIALIZE A RAY CLUSTER IN CDSW/CML SESSION 15 | ray_head = ray.init(dashboard_port=RAY_DASHBOARD_PORT) 16 | ray_nodes = cdsw.launch_workers( 17 | n=WORKERS, 18 | cpu=CPUS, 19 | memory=MEMORY, 20 | kernel="python3", 21 | code=f"!ray start --num-cpus={CPUS} --address={ray_head['redis_address']}; while true; do sleep 10; done", 22 | ) 23 | print( 24 | f"""http://read-only-{os.getenv('CDSW_MASTER_ID')}.{os.getenv("CDSW_DOMAIN")}""" 25 | ) 26 | # Set environment variable so other scripts can access the head address 27 | os.environ["RAY_CLUSTER_ADDRESS"] = ray_head["redis_address"] 28 | 29 | 30 | ### RUN THESE LINES TO TEAR DOWN RAY CLUSTER WHEN FINISHED 31 | ray.shutdown() 32 | cdsw.stop_workers(*[worker["id"] for worker in ray_nodes]) 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /scripts/train_w2v_with_logging.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import numpy as np 3 | import matplotlib.pyplot as plt 4 | 5 | from ray.tune import Analysis 6 | 7 | from recsys.data import load_ecomm, train_test_split 8 | from recsys.models import train_w2v, RecallAtKLogger, LossLogger 9 | from recsys.metrics import recall_at_k, mrr_at_k 10 | from recsys.utils import absolute_filename, pickle_save 11 | 12 | parser = argparse.ArgumentParser() 13 | parser.add_argument( 14 | "--name", 15 | help="Directory for HPO experiment results -- providing this will result in a W2V model \ 16 | trained with the best hyperparameters from that experiment. (Note: if not provided, \ 17 | default W2V hyperparameters are used instead. These can be modified directly in this script." 18 | ) 19 | parser.add_argument( 20 | "-k", default=10, 21 | help="Number of recommendations to generate for model evaluation. Default is 10." 22 | ) 23 | parser.add_argument( 24 | "--outdir", 25 | help="Directory in which to save trained model embeddings and training metrics. Default is `output/`", 26 | default=absolute_filename("output/") 27 | ) 28 | args = parser.parse_known_args() 29 | 30 | 31 | # load data 32 | sessions = load_ecomm() 33 | train, test, valid = train_test_split(sessions, test_size=1000) 34 | 35 | # determine word2vec parameters to train with 36 | if args.name: 37 | analysis = Analysis(absolute_filename("ray_results", args.name), 38 | default_metric="recall_at_k", 39 | default_mode="max") 40 | 41 | w2v_params = analysis.get_best_config() 42 | else: 43 | # These the few required parameters for training Word2Vec for this use case. 44 | # All other parameters will rely on Gensim defaults. 45 | w2v_params = { 46 | "min_count": 1, 47 | "iter": 5, 48 | "workers": 10, 49 | "sg": 1, 50 | } 51 | 52 | # Instantiate callback to measurs Recall@K on the validation set after each epoch of training 53 | ratk_logger = RecallAtKLogger(valid, k=args.k, save_model=True) 54 | # Instantiate callback to compute Word2Vec's training loss on the training set after each epoch of training 55 | loss_logger = LossLogger() 56 | # Train Word2Vec model and retrieve trained embeddings 57 | embeddings = train_w2v(train, w2v_params, [ratk_logger, loss_logger]) 58 | 59 | # Save results 60 | pickle_save(ratk_logger.recall_scores, absolute_filename(args.outdir, f"recall@k_per_epoch.pkl")) 61 | pickle_save(loss_logger.training_loss, absolute_filename(args.outdir, f"trainloss_per_epoch.pkl")) 62 | 63 | # Save trained embeddings 64 | embeddings.save(absolute_filename(args.outdir, f"embeddings.wv")) 65 | 66 | # Visualize metrics as a function of epoch 67 | plt.plot(np.array(ratk_logger.recall_scores)/np.max(ratk_logger.recall_scores)) 68 | plt.plot(np.array(loss_logger.training_loss)/np.max(loss_logger.training_loss)) 69 | plt.show() 70 | 71 | # Print results on the test set 72 | print(recall_at_k(test, embeddings, k=args.k)) 73 | print(mrr_at_k(test, embeddings, k=args.k)) 74 | 75 | -------------------------------------------------------------------------------- /scripts/tune_w2v_with_ray.py: -------------------------------------------------------------------------------- 1 | import os 2 | import argparse 3 | import numpy as np 4 | 5 | import ray 6 | from ray import tune 7 | from ray.tune.schedulers import ASHAScheduler 8 | 9 | from recsys.models import tune_w2v 10 | from recsys.utils import pickle_save, absolute_filename 11 | 12 | RAY_CLUSTER_ADDRESS = os.getenv("RAY_CLUSTER_ADDRESS") # For use in CDSW/CML 13 | 14 | parser = argparse.ArgumentParser() 15 | parser.add_argument( 16 | "--name", 17 | help="Directory name for HPO experiment results.", 18 | required=False 19 | ) 20 | parser.add_argument( 21 | "--smoke-test", action="store_true", help="Finish quickly for testing" 22 | ) 23 | parser.add_argument( 24 | "--ray-address", 25 | help="Address of Ray cluster for seamless distributed execution.", 26 | required=False, 27 | ) 28 | parser.add_argument( 29 | "-cml", 30 | help="Set this flag if using CDSW or CML for seamless distributed execution", 31 | action="store_true" 32 | ) 33 | parser.add_argument( 34 | "--asha", 35 | help="Enable an ASHA Scheduler to stop underperforming trials early during hyperparameter sweep", 36 | action="store_true" 37 | ) 38 | args, _ = parser.parse_known_args() 39 | 40 | 41 | # If necessary, connect to an existing Ray Cluster for distributed execution 42 | if args.ray_address: 43 | ray.init(address=args.ray_address) 44 | if args.cml: 45 | ray.init(address=RAY_CLUSTER_ADDRESS) 46 | 47 | # Define the hyperparameter search space for Word2Vec algorithm 48 | search_space = { 49 | "dataset": "ecomm", 50 | "k": 10, 51 | #"size": tune.grid_search(list(np.arange(10,106, 6))), 52 | #"window": tune.grid_search(list(np.arange(1,22, 3))), 53 | #"ns_exponent": tune.grid_search(list(np.arange(-1, 1.2, .2))), 54 | #"alpha": tune.grid_search([0.001, 0.01, 0.1]), 55 | "negative": tune.grid_search(list(np.arange(1,22, 3))), 56 | "iter": 10, 57 | "min_count": 1, 58 | "workers": 6, 59 | "sg": 1, 60 | } 61 | 62 | # The ASHA Scheduler will stop underperforming trials in a principled fashion 63 | asha_scheduler = ASHAScheduler(max_t=100, grace_period=10) if args.asha else None 64 | 65 | # Set the stopping critera -- use the smoke-test arg to test the system 66 | stopping_criteria = {"training_iteration": 1 if args.smoke_test else 9999} 67 | 68 | # Perform hyperparamter sweep with Ray Tune 69 | analysis = tune.run( 70 | tune_w2v, 71 | name=args.name, 72 | local_dir=absolute_filename("ray_results"), 73 | metric="recall_at_k", 74 | mode="max", 75 | scheduler=asha_scheduler, 76 | stop=stopping_criteria, 77 | num_samples=1, 78 | verbose=1, 79 | resources_per_trial={ 80 | "cpu": 1, 81 | "gpu": 0 82 | }, 83 | config=search_space, 84 | ) 85 | print("Best hyperparameters found were: ", analysis.best_config) 86 | 87 | """ 88 | # Plot all trials as a function of epochs 89 | dfs = analysis.trial_dataframes 90 | ax = None 91 | for d in dfs.values(): 92 | ax = d.recall_at_k.plot(ax=ax, legend=False) 93 | ax.set_xlabel("Epochs"); 94 | ax.set_ylabel("Recall@10"); 95 | """ 96 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="recsys", 5 | version="0.0.1", 6 | description=""" 7 | Utilities for a session-based recommendation system using Word2Vec. 8 | """, 9 | author="Melanie Beck & Nisha Muktewar", 10 | ) --------------------------------------------------------------------------------