Repository: oni-on/item-collaborative-filtering (language: python)

Issue:
Make the code a pip installable package
- add class docstring (following sklearn convention)



Discussion / hints:
@fbarl what do you think of the package names `itemrec` or `item_cf` (stands for collaborative filtering)?

According to this [guide](https://www.python.org/dev/peps/pep-0008/#package-and-module-names):
> Python packages should also have short, all-lowercase names, although the use of underscores is discouraged.
I don't really know what to say, seems like a tough one :) `itemrec` sounds too generic, `item_cf` is good if the _item_ part is more relevant than _collaborative filtering_.

Some other ideas: `amazon_icf`, `icf_recommender`, `item_collaborative_filtering`, `item2item`

I don't think the name you choose will be as important as the tags you decide to use e.g. on Github, so I wouldn't waste too much time on this decision.

Thanks @fbarl ! In the end I'll go with `icf_recommender` because I think it's a good idea to have the word recommender in there

Fix patch:
diff --git a/.gitignore b/.gitignore
index 5c5ffff..bb95f25 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,15 @@
 .venv
 .idea
 __pycache__
+.ipynb_checkpoints/
+
+# Compiled python modules.
+*.pyc
+
+# Setuptools distribution folder.
+/dist/
+/build/
+
+# Python egg metadata, regenerated from source files by setuptools.
+/*.egg-info
+/*.egg
\ No newline at end of file
diff --git a/README.md b/README.md
index 1d09b68..a2d09c1 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,16 @@
-# item-item-collaborative-filtering
-Implementation of Amazon's [Item-Item Collaborative Filtering](https://www.computer.org/csdl/mags/ic/2017/03/mic2017030012.pdf)
+# item-collaborative-filtering
+item-collaborative-filtering is a Python module for recommendation systems 
+which implements the item based collaborative filtering algorithm published by
+[Amazon](https://www.computer.org/csdl/mags/ic/2017/03/mic2017030012.pdf).
 
-## Requirements
-* Python 3.6+
-* Virtualenv
-* Pip3
+## Installation
+### Dependencies
+item-collaborative-filtering requires:
+* Python (>= 3.6)
+### User Installation
+```commandline
+pip install icf-recommender
+```
 
 ## Setup
 Create the environment and install the packages listed in `requirements.txt`
@@ -13,10 +19,26 @@ with the following command:
 ```commandline
 make requirements
 ```
+## Development
+### Source Code
+You can check the latest sources with the command:
 
-## Testing
+```commandline
+git clone https://github.com/oni-on/item-collaborative-filtering
+```
+### Requirements
+* Virtualenv
+* Pip3
+
+Install requirements with the following command
+
+```commandline
+make requirements
+```
+
+### Testing
 Run unit tests with the following command:
 
 ```commandline
 make test
-```
+```
\ No newline at end of file
diff --git a/icf_recommender/__init__.py b/icf_recommender/__init__.py
new file mode 100644
index 0000000..75399b0
--- /dev/null
+++ b/icf_recommender/__init__.py
@@ -0,0 +1,3 @@
+name = "icf_recommender"
+
+from .item_collaborative_filtering import ItemCollaborativeFiltering
diff --git a/src/item_collaborative_filtering.py b/icf_recommender/item_collaborative_filtering.py
similarity index 70%
rename from src/item_collaborative_filtering.py
rename to icf_recommender/item_collaborative_filtering.py
index 33c2943..749d18d 100644
--- a/src/item_collaborative_filtering.py
+++ b/icf_recommender/item_collaborative_filtering.py
@@ -1,19 +1,62 @@
 """
-Item to Item Collaborative Filtering
-Paper: https://www.computer.org/csdl/mags/ic/2017/03/mic2017030012.pdf
+Item Based Collaborative Filtering
 """
+
+# Author Oni On <oni.on.qepa@gmail.com>
+
 from itertools import permutations
 
-from joblib import Parallel, delayed
 import numpy as np
 import pandas as pd
 
 
-class ItemItemCollaborativeFiltering:
+class ItemCollaborativeFiltering:
+    """
+    Item Based Collaborative Filtering: a recommendation algorithm by Amazon
+
+    In a nutshell, the algorithm looks as follows:
+
+    1. Create pairs of items (X, Y) using all the items in the catalog.
+    2. For every item pair (X, Y) compute a score for the statement
+    "Users who viewed/watched/bought X, also viewed/watched/bought Y"
+    3. For a given item X, the items Y are sorted according to the score in a decreasing order.
+    The top items Y are recommended to all users who viewed/watched/bought product X.
+
+    For more details check the project `Wiki <https://github.com/oni-on/item-collaborative-filtering/wiki>`
+
+    Parameters
+    ----------
+    item_column: str, default='item_id'. The name of the item id column in the dataframe
+    user_column: str, default='user_id'. The name of the user id column in the dataframe
+
+    Examples
+    --------
+    >>> import pandas as pd
+    >>> import numpy as np
+    >>> import icf_recommender
+    >>> df = pd.read_csv("https://raw.githubusercontent.com/zygmuntz/goodbooks-10k/master/ratings.csv")
+    >>> df['liked'] = np.where(df.rating >= 5, 1, 0)
+    >>> df_liked = df.loc[df.liked == 1, ["user_id", "book_id"]]
+    >>> recommender = icf_recommender.ItemCollaborativeFiltering(item_column='book_id')
+    >>> df_recommendations = recommender.fit_recommendations(df=df_liked, item=4081)
+    >>> recommended_books = recommender.recommend(df_recommendations, 4081)
+
+    Attributes
+    ----------
+
+    References
+    ----------
+    https://www.computer.org/csdl/mags/ic/2017/03/mic2017030012.pdf
+    """
 
     def __init__(self, item_column='item_id', user_column='user_id'):
+        """
+        :param item_column: str, default='item_id'. The name of the item id column in the input dataframe
+        :param user_column: str, default='user_id'. The name of the user id column in the input dataframe
+        """
         self.item_column = item_column
         self.user_column = user_column
+        self.df_recommendations = pd.DataFrame()
 
     def __generate_item_pairs(self, df, item):
         """
@@ -160,13 +203,12 @@ def __recommendations_score_function(expected_users, actual_users):
         """
         return (actual_users - expected_users) * np.log(actual_users + 0.1) / np.sqrt(expected_users)
 
-    def fit_recommendations(self, df, item=None, processes=2):
+    def fit_recommendations(self, df, item=None):
         """
         Computes recommendation strength for item pairs
         By default item=None, means recommendations are computed for all items
         :param df: dataframe with columns [user_id, item_id]
         :param item: item that is scored with all other items, if None - all possible item pairs are scored
-        :param processes: (int) number of processes used for parallel computation
         :return: dataframe with columns [item, recommended_item, actual_common_users, expected_common_users, score]
         """
         item_pairs = self.__generate_item_pairs(df, item)
@@ -176,9 +218,7 @@ def fit_recommendations(self, df, item=None, processes=2):
         self.__calculate_user_interactions(df)
 
         # output: [((item1, item2), common_users)]
-        count_pair_users = Parallel(n_jobs=processes)(
-           delayed(self.__count_common_item_pair_users)(item_pair) for item_pair in item_pairs
-        )
+        count_pair_users = [self.__count_common_item_pair_users(item_pair) for item_pair in item_pairs]
 
         # filter out item pairs with no users in common
         count_pair_users = list(
@@ -193,21 +233,40 @@ def fit_recommendations(self, df, item=None, processes=2):
 
         # output: [expected_users]
         # compute expected users for item pairs with at least 1 user in common
-        expected_pair_users = Parallel(n_jobs=processes)(
-           delayed(self.__expected_common_item_pair_users)(df, item_pair) for item_pair in filtered_item_pairs
-        )
+        expected_pair_users = [self.__expected_common_item_pair_users(df, item_pair)
+                               for item_pair in filtered_item_pairs]
 
         # recommendation score function
         pair_score = self.__recommendations_score_function(np.array(expected_pair_users), np.array(count_pair_users))
 
         items, recommended_items = zip(*filtered_item_pairs)
 
-        df = pd.DataFrame({
-           'item': items,
-           'recommended_item': recommended_items,
-           'count_common_users': count_pair_users,
-           'expected_common_users': expected_pair_users,
-           'score': pair_score
+        df_recommendations = pd.DataFrame({
+            'item': items,
+            'recommended_item': recommended_items,
+            'count_common_users': count_pair_users,
+            'expected_common_users': expected_pair_users,
+            'score': pair_score
         })
 
-        return df
+        return df_recommendations
+
+    @staticmethod
+    def recommend(df_recommendations, item, n_recommendations=10):
+        """
+        Returns item recommendations
+        :param df_recommendations: dataframe with recommendations generated with fit_recommendations()
+        :param item: the item for which recommendations are generated
+        :param n_recommendations: the number of generated recommendations
+        :return: list of recommended products
+        """
+
+        recommended_items = df_recommendations[df_recommendations.item == item]
+        recommended_items = recommended_items.sort_values(
+            'score', ascending=False
+        ).groupby(
+            'item'
+        ).head(n_recommendations)['recommended_item'].values
+        return recommended_items
+
+
diff --git a/requirements.txt b/requirements.txt
index 5cec5c7..9940fec 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,2 @@
-joblib==0.13.0
 numpy==1.15.4
 pandas==0.23.4
-python-dateutil==2.7.5
-pytz==2018.7
-six==1.11.0
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..45e89c3
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,26 @@
+import setuptools
+
+with open("README.md", "r") as fh:
+    long_description = fh.read()
+
+setuptools.setup(
+    name="icf_recommender",
+    version="0.0.1",
+    author="Oni On",
+    author_email="oni.on.qepa@gmail.com",
+    description="Recommendation Algorithm: Item Collaborative Filtering",
+    long_description=long_description,
+    long_description_content_type="text/markdown",
+    url="https://github.com/oni-on/item-collaborative-filtering",
+    packages=setuptools.find_packages(),
+    classifiers=[
+        "Programming Language :: Python :: 3",
+        "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
+        "Operating System :: OS Independent",
+    ],
+    install_requires=[
+        'numpy==1.15.4',
+        'pandas==0.23.4'
+    ],
+    python_requires='>=3'
+)
diff --git a/src/__init__.py b/src/__init__.py
deleted file mode 100644
index e69de29..0000000


Test patch:
diff --git a/tests/test_item_collaborative_filtering.py b/tests/test_item_collaborative_filtering.py
index f469283..3cc10c6 100644
--- a/tests/test_item_collaborative_filtering.py
+++ b/tests/test_item_collaborative_filtering.py
@@ -3,39 +3,52 @@
 import numpy as np
 import pandas as pd
 
-from src.item_collaborative_filtering import ItemItemCollaborativeFiltering
+from icf_recommender.item_collaborative_filtering import ItemCollaborativeFiltering
+
 
 class TestItemItemCollaborativeFiltering(unittest.TestCase):
 
     def setUp(self):
-        self.item_item_collaborative_filtering = ItemItemCollaborativeFiltering()
+        self.item_collaborative_filtering = ItemCollaborativeFiltering()
 
     def test_fit_recommendations(self):
         df = pd.DataFrame({'item_id': ['A', 'A', 'A', 'A', 'B', 'B', 'B'],
                            'user_id': [1, 2, 3, 4, 1, 2, 5]})
 
-        recommendations = self.item_item_collaborative_filtering.fit_recommendations(df)
+        recommendations = self.item_collaborative_filtering.fit_recommendations(df)
 
         self.assertTrue(
-            (recommendations.item.values==np.array(['A', 'B'])).all(),
+            (recommendations.item.values == np.array(['A', 'B'])).all(),
             msg="Recommendations dataframe 'item' column values not as expected"
         )
         self.assertTrue(
-            (recommendations.recommended_item.values==np.array(['B', 'A'])).all(),
+            (recommendations.recommended_item.values == np.array(['B', 'A'])).all(),
             msg="Recommendations dataframe 'recommended_item' column values not as expected"
         )
         self.assertTrue(
-            (recommendations.count_common_users.values==np.array([2, 2])).all(),
+            (recommendations.count_common_users.values == np.array([2, 2])).all(),
             msg="__count_common_item_pair_users() - unexpected results"
         )
         self.assertTrue(
-            (recommendations.expected_common_users.values==np.array([6/5, 8/5])).all(),
+            (recommendations.expected_common_users.values == np.array([6/5, 8/5])).all(),
             msg="__expected_common_item_pair_users() - unexpected results"
         )
         self.assertTrue(
-            (recommendations.score.values.round(4)==np.array([0.5418, 0.2346])).all(),
+            (recommendations.score.values.round(4) == np.array([0.5418, 0.2346])).all(),
             msg="recommendations scores - unexpected results"
         )
 
+    def test_recommend(self):
+        df = pd.DataFrame({'item_id': ['A', 'A', 'A', 'A', 'B', 'B', 'B'],
+                           'user_id': [1, 2, 3, 4, 1, 2, 5]})
+
+        df_recommendations = self.item_collaborative_filtering.fit_recommendations(df)
+
+        top_recommendations = self.item_collaborative_filtering.recommend(df_recommendations, 'A')
+        self.assertTrue(
+            top_recommendations == ['B'],
+            msg="unexpected results from recommend()"
+        )
+
     def tearDown(self):
         pass