Repository: JetBrains-Research/batcore (language: python)

Issue:
refactoring + pypi setup



Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 6bf25ae..c7bd63a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,16 @@ test.py
 other_data/Android.json
 notebooks/Counter.ipynb
 review.openstack.org
-/other_data/
\ No newline at end of file
+/other_data/
+batcore.egg-info
+checkpoints
+data_checkpoint
+dist
+raw_dataset
+results
+tests
+*.ipynb
+*miss*
+sandbox.py
+logs
+
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 0000000..21fd491
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,24 @@
+version: 2
+
+# Set the version of Python and other tools you might need
+#build:
+#  os: ubuntu-22.04
+#  tools:
+#    python: "3.11"
+#    # You can also specify other tool versions:
+#    # nodejs: "19"
+#    # rust: "1.64"
+#    # golang: "1.19"
+
+# Build documentation in the docs/ directory with Sphinx
+sphinx:
+   configuration: docs/conf.py
+
+# If using Sphinx, optionally build your docs in additional formats such as PDF
+# formats:
+#    - pdf
+
+# Optionally declare the Python requirements required to build your docs
+python:
+   install:
+   - requirements: docs/requirements.txt
\ No newline at end of file
diff --git a/AliasMatching/utils.py b/AliasMatching/utils.py
deleted file mode 100644
index a748d79..0000000
--- a/AliasMatching/utils.py
+++ /dev/null
@@ -1,211 +0,0 @@
-import re
-
-import numpy as np
-import pandas as pd
-from sklearn.cluster import AgglomerativeClustering
-from Levenshtein import distance as LevDist
-import string
-from tqdm import tqdm
-from collections import defaultdict
-
-
-def remove_punctuation(name):
-    """
-    removes punctuation
-    """
-    return name.translate(str.maketrans('', '', string.punctuation)).lower()
-
-
-# prefixes and titles to remove from name
-fixes = ['jr', 'sr', 'dr', 'mr', 'mrs']
-titles = ['admin', 'support']
-ban_words = fixes + titles
-
-
-def remove_ban_words(name):
-    """
-    removes banned words from name
-    """
-    name_parts = []
-    for p in name.split():
-        if p not in ban_words:
-            name_parts.append(p)
-    return " ".join(name_parts)
-
-
-def name_preprocess(name):
-    """
-    removes punctuation and banned words from the name
-    """
-    return remove_ban_words(remove_punctuation(name))
-
-
-def first_name(name):
-    """
-    returns first name
-    """
-    if name == '':
-        return name
-    return name.split()[0]
-
-
-def last_name(name):
-    """
-    return last name
-    """
-    if name == '':
-        return ''
-    return name.split()[-1]
-
-
-def shorten_email(email):
-    """
-    returns shorten form of the e-mail (everything before @)
-    """
-    if email == '':
-        return ''
-    return email.split('@')[0]
-
-
-def get_norm_levdist(str1, str2):
-    """
-    Normalised Levenshtein distance between two strings
-    """
-    ld = LevDist(str1, str2)
-    ml = max(len(str1), len(str2))
-
-    if ml == 0:
-        return 0
-
-    score = ld / ml
-
-    return score
-
-
-def name_handle_dist(name, handle):
-    """
-    checks if first and second name are in the handle (e-mail or login)
-    """
-
-    fn, ln = name
-    if len(fn) > 1 and len(ln) > 1:
-        if fn in handle and ln in handle:
-            return 0
-
-    # if len(fn) > 0 and len(ln) > 0:
-    #     names_with_initials = [fn[0] + ln, fn + ln[0], ln + fn[0], ln[0] + fn]
-    #     for nwi in names_with_initials:
-    #         if len(nwi) > 2 and nwi in email:
-    #             return 0
-    return 1
-
-
-def sim_users(u1, u2):
-    """
-    similarity of two users name based on their name and e-mail
-    """
-    # name scores
-    if u1['name'] is np.nan or u2['name'] is np.nan:
-        name_score = 1
-    else:
-        full_name_score = get_norm_levdist(u1['name'], u2['name'])
-
-        part_name_score = get_norm_levdist(u1['first_name'], u2['first_name']) + get_norm_levdist(u1['last_name'],
-                                                                                                  u2['last_name'])
-        part_name_score /= 2
-
-        email_name_score = max(name_handle_dist((u1['first_name'], u1['last_name']),
-                                                u2['email']),
-                               name_handle_dist((u2['first_name'], u2['last_name']),
-                                                u1['email'])
-                               )
-
-        name_score = min(full_name_score, part_name_score, email_name_score)
-
-    # handle score
-
-    email_score = 1
-    if not u1['short_email'] is np.nan and not u2['short_email'] is np.nan:
-        if len(u1['short_email']) > 2 and len(u2['short_email']) > 2:
-            email_score = get_norm_levdist(u1['short_email'], u2['short_email'])
-
-    login_score = 1
-    if not u1['login'] is np.nan and not u2['login'] is np.nan:
-        if len(u1['login']) > 2 and len(u2['login']) > 2:
-            login_score = get_norm_levdist(u1['login'], u2['login'])
-
-    # login_email_score = 1
-    # if not u1['login'] is np.nan and not u2['short_email'] is np.nan:
-    #     if len(u1['login']) > 2 and len(u2['short_email']) > 2:
-    #         login_email_score = get_norm_levdist(u1['login'], u2['short_email'])
-
-    # if not u1['short_email'] is np.nan and not u2['login'] is np.nan:
-    #     if len(u1['short_email']) > 2 and len(u2['login']) > 2:
-    #         login_email_score = min(login_email_score, get_norm_levdist(u1['short_email'], u2['login']))
-
-    handle_score = min(email_score, login_score)
-
-    return min(name_score, handle_score)
-
-
-def get_sim_matrix(users):
-    """
-    calculates name similarity matrix between users
-    """
-    sim_matrix = np.zeros((len(users), len(users)))
-    for i1, row1 in tqdm(users.iterrows()):
-        def score(i2, row2):
-            if i1 < i2:
-                return sim_users(row1, row2)
-            if i1 == i2:
-                return 0
-            return 0
-
-        # sim_matrix[i1] = pool.starmap(score, users.iterrows())
-        sim_matrix[i1] = [score(*p) for p in users.iterrows()]
-    sim_matrix = sim_matrix + sim_matrix.T
-
-    return sim_matrix
-
-
-def get_clusters(users, distance_threshold=0.1):
-    """
-    Adaptation of the approach from the 'Mining Email Social Networks'. Algorithm measures pair-wise similarity
-    between all participants and splits them into clusters with Agglomerative clustering.
-    :param users: dataframe with users names, e-mails, and logins
-    :param distance_threshold: distance parameter for clustering
-    :return: dict with provides cluster id for each user
-    """
-    users = users.drop_duplicates().reset_index().drop('index', axis=1)
-    users = users.fillna('')
-    users.name = users.name.apply(name_preprocess)
-
-    users['short_email'] = users.email.apply(shorten_email)
-    users['first_name'] = users.name.apply(first_name)
-    users['last_name'] = users.name.apply(last_name)
-
-    # ban_mask = ~users.apply(ban_users, axis=1, ban_list=bot_users, project=project)
-    # users = users[ban_mask]
-    users = users.reset_index().drop('index', axis=1)
-
-    sim_matrix = get_sim_matrix(users)
-    agg = AgglomerativeClustering(n_clusters=None,
-                                  distance_threshold=distance_threshold,
-                                  affinity='precomputed',
-                                  linkage='complete').fit(sim_matrix)
-
-    users['cluster'] = agg.labels_
-    df_cs = users[['cluster', 'name']].groupby('cluster').count().reset_index().rename({'name': 'cluster_size'},
-                                                                                       axis=1)
-
-    users = users.join(df_cs, on='cluster', rsuffix='_r').drop('cluster_r', axis=1)
-    users = users.sort_values(['cluster_size', 'cluster'], ascending=False).reset_index().drop('index', axis=1)
-
-    users['cluster2'] = -users.index - 1
-
-    users.loc[users['cluster'].isna(), 'cluster'] = users['cluster2'][users['cluster'].isna()]
-    users['id'] = pd.factorize(users['cluster'])[0]
-
-    key2id = {x['initial_id']: x['id'] for _, x in users.iterrows()}
-    key2id = defaultdict(lambda: np.nan, key2id)
-    return key2id
diff --git a/Counter/CoreWorkloadCounter.py b/Counter/CoreWorkloadCounter.py
deleted file mode 100644
index b19d9fb..0000000
--- a/Counter/CoreWorkloadCounter.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from collections import defaultdict
-
-
-def CoreWorkloadCounter(history, from_date=None, to_date=None):
-    """
-    Core Workload estimates amount of work for core developers. Core Workload equals to the total number of reviews
-    performed by 10 reviews that reviewed the most
-    :param history: data with reviews
-    :param from_date: start of the period on which CoreWorkload is calculated. If None starts from the start of history
-    :param to_date: end of the period on which CoreWorkload is calculated. If None ends at the end of history
-    :return: Core Workload metrics
-    """
-    if from_date is None:
-        from_date = history[0]['date']
-    if to_date is None:
-        to_date = history[-1]['date']
-
-    review_count = defaultdict(lambda: 0)
-    for pull in history:
-        if pull['date'] < from_date:
-            continue
-        if pull['date'] > to_date:
-            break
-
-        for reviewer in pull['reviewer_login']:
-            review_count[reviewer] += 1
-
-    sorted_counts = sorted(review_count.values(), key=lambda x: -x)
-    core_workload = sum(sorted_counts[:10])
-
-    return core_workload
diff --git a/Metrics/__init__.py b/Metrics/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/RecommenderBase/__init__.py b/RecommenderBase/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/baselines/PRCF/__init__.py b/baselines/PRCF/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/baselines/models/WRC.py b/baselines/models/WRC.py
deleted file mode 100644
index ce42e04..0000000
--- a/baselines/models/WRC.py
+++ /dev/null
@@ -1,109 +0,0 @@
-from collections import defaultdict
-from multiprocessing import Pool
-from itertools import product
-from functools import partial
-import numpy as np
-
-from RecommenderBase.recommender import BanRecommenderBase
-from ..utils import LCP, path2list
-
-
-def count_score(f1, f2, i, wrc=None, files=None):
-    val = wrc[files.getid(f2), i]
-    if val >= 0:
-        return val * LCP(path2list(f1), path2list(f2))
-    return 0
-
-
-class WRC(BanRecommenderBase):
-    """
-    WRC recommends reviewers based on the file path similarity measures. Files contribute to the final score not only
-    based on their similary but also on their recency and size of their pull request.
-
-    dataset - StandardDataset(data, user_items=True, file_items=True)
-
-    Paper : "Automatically Recommending Code Reviewers Based on Their Expertise: An Empirical Comparison"
-    """
-
-    def __init__(self,
-                 items2ids,
-                 delta=1,
-                 no_owner=True,
-                 no_inactive=True,
-                 inactive_time=60):
-        """
-        :param items2ids: dict with user2id and file2id
-        :param delta: time decay factor for weight of the previous reviews
-        :param no_owner: flag to add or remove owners of the pull request from the recommendations
-        :param no_inactive: flag to add or remove inactive reviewers from recommendations
-        :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
-        """
-        super().__init__(no_owner, no_inactive, inactive_time)
-
-        self.files = items2ids['files']
-        self.users = items2ids['users']
-
-        self.delta = delta
-        self.reviews = []
-
-        # matrix of wrc scores for (user, files) pairs
-        self.wrc = np.zeros((len(self.files), len(self.users)))
-
-        self.known_files = set()
-
-        # self.scores = {}
-        # self.p = Pool(5)
-        self.lcp_results = -np.ones((len(self.files), len(self.files)))
-
-    def LCP_count(self, f1, f2):
-        f1_id = self.files.getid(f1)
-        f2_id = self.files.getid(f2)
-
-        if self.lcp_results[f1_id, f2_id] == -1:
-            self.lcp_results[f1_id, f2_id] = LCP(path2list(f1), path2list(f2))
-
-        return self.lcp_results[f1_id, f2_id]
-
-    def predict(self, pull, n=10):
-        """
-        counts sum of all wrc score for each possible reviewer and files in the pull
-        :param n: number of reviewers to recommend
-        """
-        scores = defaultdict(lambda: 0)
-        for f1 in self.known_files:
-            for f2 in pull['file_path']:
-                for i in range(self.wrc.shape[1]):
-                    val = self.wrc[self.files.getid(f2), i]
-                    if val >= 0:
-                        scores[self.users[i]] += val * self.LCP_count(f1, f2)  # LCP(path2list(f1), path2list(f2))
-
-        # params = product(self.known_files, pull['file_path'], range(self.wrc.shape[1]))
-        # res = [count_score(f1, f2, i, self.wrc, self.files) for f1, f2, i in params]
-
-        # res = self.p.starmap(partial(count_score, wrc=self.wrc, files=self.files),
-        #                      product(self.known_files, pull['file_path'], range(self.wrc.shape[1])))
-        #
-        # params = product(self.known_files, pull['file_path'], range(self.wrc.shape[1]))
-        # for (f1, f2, i), r in zip(params, res):
-        #     scores[self.users[i]] += r
-
-        self.filter(scores, pull)
-        sorted_users = sorted(scores.keys(), key=lambda x: -scores[x])
-
-        return sorted_users[:n]
-
-    def fit(self, data):
-        """
-        updates wrc matrix
-        """
-        if self.no_inactive:
-            self.update_time(data)
-        for event in data:
-            if event['type'] == 'pull':
-                self.reviews.append(event)
-                self.wrc = self.delta * self.wrc
-
-                for file in event['file_path']:
-                    self.known_files.add(file)
-                    for user in event['reviewer_login']:
-                        self.wrc[self.files.getid(file), self.users.getid(user)] += 1 / len(event['reviewer_login'])
diff --git a/baselines/models/__init__.py b/baselines/models/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/batcore/__init__.py b/batcore/__init__.py
new file mode 100644
index 0000000..3d71669
--- /dev/null
+++ b/batcore/__init__.py
@@ -0,0 +1,1 @@
+__all__ = ['batcore']
diff --git a/baselines/PRCF/PRCF.py b/batcore/baselines/PRCF/PRCF.py
similarity index 97%
rename from baselines/PRCF/PRCF.py
rename to batcore/baselines/PRCF/PRCF.py
index 23e5a9f..dacb7b0 100644
--- a/baselines/PRCF/PRCF.py
+++ b/batcore/baselines/PRCF/PRCF.py
@@ -3,8 +3,8 @@
 import numpy as np
 from scipy.sparse import dok_matrix
 
-from RecommenderBase.recommender import RecommenderBase, BanRecommenderBase
-from baselines.PRCF.utils import pearson, LCP
+from batcore.modelbase.recommender import BanRecommenderBase
+from batcore.baselines.PRCF.utils import pearson, LCP
 
 
 class PRCF(BanRecommenderBase):
diff --git a/AliasMatching/__init__.py b/batcore/baselines/PRCF/__init__.py
similarity index 100%
rename from AliasMatching/__init__.py
rename to batcore/baselines/PRCF/__init__.py
diff --git a/baselines/PRCF/utils.py b/batcore/baselines/PRCF/utils.py
similarity index 100%
rename from baselines/PRCF/utils.py
rename to batcore/baselines/PRCF/utils.py
diff --git a/baselines/__init__.py b/batcore/baselines/__init__.py
similarity index 100%
rename from baselines/__init__.py
rename to batcore/baselines/__init__.py
diff --git a/baselines/models/ACRec.py b/batcore/baselines/models/ACRec.py
similarity index 72%
rename from baselines/models/ACRec.py
rename to batcore/baselines/models/ACRec.py
index 2c9cc72..f8bc890 100644
--- a/baselines/models/ACRec.py
+++ b/batcore/baselines/models/ACRec.py
@@ -1,7 +1,7 @@
 from collections import defaultdict
 from datetime import timedelta
 
-from RecommenderBase.recommender import BanRecommenderBase
+from batcore.modelbase.recommender import BanRecommenderBase
 
 
 class ACRec(BanRecommenderBase):
@@ -10,10 +10,13 @@ class ACRec(BanRecommenderBase):
     previous reviews in a given timeframe and for each comment assigns its commenter a score based on the time
     passed. Candidates with the best accumulated scores are suggested as reviewers
 
-    dataset - StandardDataset(data, comments=True)
+    Paper: `Who Should Comment on This Pull Request? Analyzing Attributes for More Accurate Commenter Recommendation in Pull-Based Development <https://www.sciencedirect.com/science/article/abs/pii/S095058491630283X?via%3Dihub>`_
 
-    Paper: "Who Should Comment on This Pull Request? Analyzing Attributes for More Accurate
-    Commenter Recommendation in Pull-Based Development"
+    :param gamma: number of days to pass for a pull request to ignored during predictions
+    :param lambd: time-decaying parameter
+    :param no_owner: flag to add or remove owners of the pull request from the recommendations
+    :param no_inactive: flag to add or remove inactive reviewers from recommendations
+    :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
     """
 
     def __init__(self,
@@ -22,13 +25,6 @@ def __init__(self,
                  no_owner=True,
                  no_inactive=True,
                  inactive_time=60):
-        """
-        :param gamma: number of days to pass for a pull request to ignored during predictions
-        :param lambd: time-decaying parameter
-        :param no_owner: flag to add or remove owners of the pull request from the recommendations
-        :param no_inactive: flag to add or remove inactive reviewers from recommendations
-        :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
-        """
         super().__init__(no_owner, no_inactive, inactive_time)
 
         self.gamma = timedelta(days=gamma)
@@ -39,11 +35,11 @@ def __init__(self,
 
     def predict(self, pull, n=10):
         """
+        goes through recent pull requests and accumulates score for each commenter based on the recency of their comment
+
         :param pull: pull requests for which reviwers are required
         :param n: number of reviewers to recommend
-        :return: at most n reviewers
-
-        goes through recent pull requests and accumulates score for each commenter based on the recency of their comment
+        :return: at most n reviewers for the pull request
         """
         scores = defaultdict(lambda: 0)
         date = pull['date']
@@ -60,8 +56,9 @@ def predict(self, pull, n=10):
 
     def fit(self, data):
         """
-        :param data: a batch of pull requests and comments
         remembers each pull request and build relation between them and comments
+
+        :param data: a batch of pull requests and comments
         """
         super().fit(data)
         for event in data:
diff --git a/baselines/models/CN.py b/batcore/baselines/models/CN.py
similarity index 84%
rename from baselines/models/CN.py
rename to batcore/baselines/models/CN.py
index 0963994..ddeaa1f 100644
--- a/baselines/models/CN.py
+++ b/batcore/baselines/models/CN.py
@@ -6,7 +6,7 @@
 import networkx as nx
 from community import community_louvain
 
-from RecommenderBase.recommender import BanRecommenderBase
+from batcore.modelbase.recommender import BanRecommenderBase
 
 
 # TODO look into multiple owners
@@ -16,9 +16,13 @@ class CN(BanRecommenderBase):
     constructed). Vertices are developers in the project and edges represents weighted number of reviewing
     interactions. Scores to each candidate are assigned based on the distance in graph
 
-    dataset - StandardDataset(data, comments=True, user_items=True)
+    Paper: `Reviewer Recommendation for Pull-Requests in GitHub: What Can We Learn from Code Review and Bug Assignment? <https://www.researchgate.net/profile/Yue-Yu-126/publication/291140377_Reviewer_Recommendation_for_Pull-Requests_in_GitHub_What_Can_We_Learn_from_Code_Review_and_Bug_Assignment/links/5a2f42a74585155b617a2177/Reviewer-Recommendation-for-Pull-Requests-in-GitHub-What-Can-We-Learn-from-Code-Review-and-Bug-Assignment.pdf>`_
 
-    Paper: "Reviewer Recommendation for Pull-Requests in GitHub: What Can We Learn from Code Review and Bug Assignment?"
+    :param items2ids: dict with user2id
+    :param lambd: weight decay coefficient for comments within a single review beyond the first one
+    :param no_owner: flag to add or remove owners of the pull request from the recommendations
+    :param no_inactive: flag to add or remove inactive reviewers from recommendations
+    :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
     """
 
     def __init__(self,
@@ -27,13 +31,7 @@ def __init__(self,
                  no_owner=True,
                  no_inactive=True,
                  inactive_time=60):
-        """
-        :param items2ids: dict with user2id
-        :param lambd: weight decay coefficient for comments within a single review beyond the first one
-        :param no_owner: flag to add or remove owners of the pull request from the recommendations
-        :param no_inactive: flag to add or remove inactive reviewers from recommendations
-        :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
-        """
+
         super().__init__(no_owner, no_inactive, inactive_time)
 
         self.users = items2ids['users']
@@ -90,8 +88,8 @@ def fit(self, data):
                 self.pull_owner[pull] = self.users.getid(owner)
 
                 self.pull_count += 1
-                if len(event['reviewer_login']):
-                    rev_ind = np.array([self.users.getid(rev) for rev in event['reviewer_login']])
+                if len(event['reviewer']):
+                    rev_ind = np.array([self.users.getid(rev) for rev in event['reviewer']])
                     self.num_revs[rev_ind] += 1
                     self.cooc[tuple(np.meshgrid(rev_ind, rev_ind))] += 1
 
@@ -162,8 +160,8 @@ def predict_apriori(self, i, k=10):
 
         best_recs = np.argpartition(-scores, k)[:k]
         sorted_recs = sorted(best_recs, key=lambda x: -self.num_revs[x])
-        if len(sorted_recs) != len(np.unique(sorted_recs)):
-            print('apriori')
+        # if len(sorted_recs) != len(np.unique(sorted_recs)):
+        #     print('apriori')
         return sorted_recs
 
     def predict_community(self, i, k=10):
@@ -207,6 +205,6 @@ def predict_community(self, i, k=10):
                 if len(recs) >= k:
                     break
             ind += 1
-        if len(recs) != len(np.unique(recs)):
-            print('com')
+        # if len(recs) != len(np.unique(recs)):
+        #     print('com')
         return recs
diff --git a/baselines/models/RevFinder.py b/batcore/baselines/models/RevFinder.py
similarity index 74%
rename from baselines/models/RevFinder.py
rename to batcore/baselines/models/RevFinder.py
index 418f160..3cad2c8 100644
--- a/baselines/models/RevFinder.py
+++ b/batcore/baselines/models/RevFinder.py
@@ -3,7 +3,7 @@
 
 import numpy as np
 
-from RecommenderBase.recommender import BanRecommenderBase
+from batcore.modelbase.recommender import BanRecommenderBase
 from ..utils import LCSubseq, LCSubstr, LCSuff, LCP
 from ..utils import get_map
 
@@ -15,11 +15,13 @@ class RevFinder(BanRecommenderBase):
     4 different file similarities metrics. For each metric list of suggestions is calculated, and then they are
     combined into one
 
+    Paper: `Who Should Review My Code? A File Location-Based Code-Reviewer Recommendation Approach for Modern Code Review <https://ieeexplore.ieee.org/document/7081824>`_
 
-    dataset - RevFinderDataset(data)
-
-    Paper : "Who Should Review My Code? A File Location-Based Code-Reviewer Recommendation Approach for Modern
-    Code Review"
+    :param items2ids: dict with all possible reviewers
+    :param max_date: time in days after which old reviews stop influence predictions
+    :param no_owner: flag to add or remove owners of the pull request from the recommendations
+    :param no_inactive: flag to add or remove inactive reviewers from recommendations
+    :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
     """
 
     def __init__(self,
@@ -28,13 +30,7 @@ def __init__(self,
                  no_owner=True,
                  no_inactive=True,
                  inactive_time=60):
-        """
-        :param items2ids: dict with all possible reviewers
-        :param max_date: time in days after which old reviews stop influence predictions
-        :param no_owner: flag to add or remove owners of the pull request from the recommendations
-        :param no_inactive: flag to add or remove inactive reviewers from recommendations
-        :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
-        """
+
         super().__init__(no_owner, no_inactive, inactive_time)
         self.history = []
         self.max_date = max_date
@@ -58,7 +54,7 @@ def predict(self, pull, n=10):
         end_time = pull["date"]
         start_time = end_time - timedelta(days=self.max_date)
 
-        cf1 = pull["file_path"][:500]
+        cf1 = pull["file"][:500]
 
         order_score = np.zeros(self.rev_count)
 
@@ -68,7 +64,7 @@ def predict(self, pull, n=10):
             if old_rev['date'] < start_time:
                 break
 
-            cf2 = old_rev["file_path"][:500]
+            cf2 = old_rev["file"][:500]
 
             for metric_id, metric in enumerate(metrics):
                 score = 0
@@ -78,7 +74,7 @@ def predict(self, pull, n=10):
                 if score > 0:
                     score /= len(cf1) * len(cf2)
 
-                for rev in old_rev['reviewer_login']:
+                for rev in old_rev['reviewer']:
                     rev_scores[metric_id][rev] += score
 
         final_score = np.zeros(self.rev_count)
@@ -104,9 +100,9 @@ def fit(self, data):
     def update_pull(self, pull):
         pull = copy.deepcopy(pull)
 
-        reviewer_indices = [self.reviewer_map[_reviewer] for _reviewer in pull["reviewer_login"]]
+        reviewer_indices = [self.reviewer_map[_reviewer] for _reviewer in pull["reviewer"]]
 
-        pull['reviewer_login'] = np.array(reviewer_indices)
-        pull['file_path'] = [f.split('/') for f in pull["file_path"]]
+        pull['reviewer'] = np.array(reviewer_indices)
+        pull['file'] = [f.split('/') for f in pull["file"]]
 
         return pull
diff --git a/baselines/models/RevRec.py b/batcore/baselines/models/RevRec.py
similarity index 83%
rename from baselines/models/RevRec.py
rename to batcore/baselines/models/RevRec.py
index 795a3e1..ef95ff0 100644
--- a/baselines/models/RevRec.py
+++ b/batcore/baselines/models/RevRec.py
@@ -4,7 +4,7 @@
 import numpy as np
 from scipy.sparse import dok_matrix
 
-from RecommenderBase.recommender import BanRecommenderBase
+from batcore.modelbase.recommender import BanRecommenderBase
 from ..utils import norm, sim
 
 
@@ -13,9 +13,23 @@ class RevRec(BanRecommenderBase):
     RevRec finds best set of reviewers based on the two metrics: group expertise on modified files and amount of
     collaborations with pull request submitter. The search for the best set is performed via genetic algorithm
 
-    dataset - RevRecDataset(data, comments=True, user_items=True)
-
-    Paper: "Search-Based Peer Reviewers Recommendation in Modern Code Review"
+    Paper: `Search-Based Peer Reviewers Recommendation in Modern Code Review <https://ieeexplore.ieee.org/document/7816482>`_
+
+    :param items2ids: dict with users2ids
+    :param k: threshold for files similarity
+    :param ga_params: dict of hyperparameters for genetic algorithm
+
+        :ga_parameter max_rev: maximum number of reviewers to recommend. default=10
+        :ga_parameter min_rev: minimum number of reviewers to recommend. default=1
+        :ga_parameter size: population size. default=20
+        :ga_parameter prob: mutation probability. default=0.1
+        :ga_parameter max_eval: number of genetic algorithm iterations. default=100
+        :ga_parameter n: number of best solutions that contribute to the sorting of the best reviewers. default=10
+        :ga_parameter alpha: weight of the reviewer expertise score. default=0.5
+        :ga_parameter beta: weight of the reviewer collaboration score. default=0.5
+    :param no_owner: flag to add or remove owners of the pull request from the recommendations
+    :param no_inactive: flag to add or remove inactive reviewers from recommendations
+    :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
     """
 
     def __init__(self,
@@ -25,28 +39,13 @@ def __init__(self,
                  no_owner=True,
                  no_inactive=True,
                  inactive_time=60):
-        """
-        :param items2ids: dict with users2ids
-        :param k: threshold for files similarity
-        :param ga_params: dict of hyperparameters for genetic algorithm
-            :ga_parameter max_rev: maximum number of reviewers to recommend. default=10
-            :ga_parameter min_rev: minimum number of reviewers to recommend. default=1
-            :ga_parameter size: population size. default=20
-            :ga_parameter prob: mutation probability. default=0.1
-            :ga_parameter max_eval: number of genetic algorithm iterations. default=100
-            :ga_parameter n: number of best solutions that contribute to the sorting of the best reviewers. default=10
-            :ga_parameter alpha: weight of the reviewer expertise score. default=0.5
-            :ga_parameter beta: weight of the reviewer collaboration score. default=0.5
-        :param no_owner: flag to add or remove owners of the pull request from the recommendations
-        :param no_inactive: flag to add or remove inactive reviewers from recommendations
-        :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
-       """
+
         super().__init__(no_owner, no_inactive, inactive_time)
 
         if ga_params is None:
             self.ga_params = {'max_rev': 10,
                               'min_rev': 1,
-                              'size': 200,
+                              'size': 20,
                               'prob': 0.1,
                               'max_eval': 100,
                               'n': 10,
@@ -133,8 +132,10 @@ def get_scores(self, population, expertise, owners):
 
     def run_ga(self, owners, expertise):
         """
-        runs a genetic algorithm for the pull request made by :param owners: and vector of candidate expertise
-        :param expertise:
+        runs a genetic algorithm to get reviewers recommendations
+
+        :param owners: owners of the pull requests for which are recommendations are made
+        :param expertise: expertise of the potential candidates
         :return: best set of reviewers and list of occurrences of each reviewer in the last population
         """
         population = np.random.normal(size=(self.ga_params['size'], self.active_revs)) > 0.2
@@ -176,7 +177,9 @@ def set_banned(self, pull):
 
     def predict(self, pull, n=10):
         """
-        predicts at most :param n: reviewers for the given pull request :param pull:
+        :param pull: pull requests for which reviwers are required
+        :param n: number of reviewers to recommend
+        :return: at most n reviewers for the pull request
         """
 
         # calculation of the candidate expertise based on the file path similarities of the changed files with
@@ -189,7 +192,7 @@ def predict(self, pull, n=10):
         cf = defaultdict(lambda: 0)
         cr = defaultdict(lambda: datetime(year=10, month=1, day=1))
 
-        for file in pull['file_path']:
+        for file in pull['file']:
             for f2 in self.com_file:
                 if sim(file, f2) > self.k:
                     for user in self.com_file[f2]:
@@ -228,7 +231,7 @@ def fit(self, data):
                 pull = event['key_change']
                 self.pull_owners[pull] = [self.users.getid(user) for user in event['owner']]
 
-                for rev in event['reviewer_login']:
+                for rev in event['reviewer']:
                     self.active_revs = max(self.active_revs, self.users.getid(rev) + 1)
 
             elif event['type'] == 'comment':
@@ -271,7 +274,7 @@ def update_time(self, events):
                         self.last_active[owner] = date
                 except KeyError:
                     pass
-                for reviewer in event['reviewer_login']:
+                for reviewer in event['reviewer']:
                     self.last_active[reviewer] = date
             elif event['type'] == 'comment':
                 date = event['date']
diff --git a/baselines/models/Tie.py b/batcore/baselines/models/Tie.py
similarity index 79%
rename from baselines/models/Tie.py
rename to batcore/baselines/models/Tie.py
index 5e3f3e5..454b993 100644
--- a/baselines/models/Tie.py
+++ b/batcore/baselines/models/Tie.py
@@ -4,7 +4,7 @@
 
 import numpy as np
 
-from RecommenderBase.recommender import BanRecommenderBase
+from batcore.modelbase.recommender import BanRecommenderBase
 from ..utils import get_map, pull_sim
 
 
@@ -14,9 +14,15 @@ class Tie(BanRecommenderBase):
     on path distance between files in current pr and previously reviewed file. Second is a score from naive Bayes
     classifier trained on the titles of prs.
 
-    dataset - TieDataset(data)
-    Paper: "Who Should Review This Change? Putting Text and File Location Analyses Together for More Accurate
-    Recommendations"
+    Paper: `Who Should Review This Change? Putting Text and File Location Analyses Together for More Accurate Recommendations <https://xin-xia.github.io/publication/icsme15.pdf>`_
+
+    :param item_list: dict with word_list and reviewer_list
+    :param text_splitter: a function to parse pull comments
+    :param alpha: weight between path-based and text-based recommenders
+    :param max_date: time in days after which reviews are not considered
+    :param no_owner: flag to add or remove owners of the pull request from the recommendations
+    :param no_inactive: flag to add or remove inactive reviewers from recommendations
+    :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
     """
 
     def __init__(self,
@@ -27,15 +33,7 @@ def __init__(self,
                  no_owner=True,
                  no_inactive=True,
                  inactive_time=60):
-        """
-        :param item_list: dict with word_list and reviewer_list
-        :param text_splitter: a function to parse pull comments
-        :param alpha: weight between path-based and text-based recommenders
-        :param max_date: time in days after which reviews are not considered
-        :param no_owner: flag to add or remove owners of the pull request from the recommendations
-        :param no_inactive: flag to add or remove inactive reviewers from recommendations
-        :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
-        """
+
         super().__init__(no_owner, no_inactive, inactive_time)
         self.history = []
         self.word_list = item_list['word_list']
@@ -55,6 +53,7 @@ def __init__(self,
     def predict(self, pull, n=10):
         """
         Recommends appropriate reviewers of the given review. This method returns `max_count` reviewers at most.
+
         :param n: number of candidates to return
         """
         pull = self.update_pull(pull)
@@ -86,7 +85,7 @@ def fit(self, data):
                 pull = event
                 pull = self.update_pull(pull)
 
-                for reviewer_index in pull["reviewer_login"]:
+                for reviewer_index in pull["reviewer"]:
                     self.review_count_map[reviewer_index] = \
                         self.review_count_map.get(reviewer_index, 0) + 1
                     for word_index in pull["title"]:
@@ -107,13 +106,13 @@ def fps_score(self, pull):
                 break
 
             c = pull_sim(old_pull, pull)
-            scores[old_pull['reviewer_login']] += c
+            scores[old_pull['reviewer']] += c
 
         return scores
 
     def bayes_score(self, pull, reviewer_index):
         """
-        Assigns score for the :param pull: to the candidate :param reviewer_index: based on naive bayes classifier
+        Assigns score to each candidate based on naive bayes classifier trained on pull titles
         """
         product = 1
         s = 0
@@ -126,7 +125,7 @@ def bayes_score(self, pull, reviewer_index):
 
     def update_pull(self, pull):
         """
-        turns title into bag of words vector and replaces reviewers with theri ids
+        turns title into bag of words vector and replaces reviewers with their ids
         """
         pull = copy.deepcopy(pull)
 
@@ -135,9 +134,9 @@ def update_pull(self, pull):
                                        self.text_splitter(pull["title"])
                                        )
                                 ))
-        reviewer_indices = [self.reviewer_map[_reviewer] for _reviewer in pull["reviewer_login"]]
+        reviewer_indices = [self.reviewer_map[_reviewer] for _reviewer in pull["reviewer"]]
 
         pull['title'] = word_indices
-        pull['reviewer_login'] = reviewer_indices
+        pull['reviewer'] = reviewer_indices
 
         return pull
diff --git a/batcore/baselines/models/WRC.py b/batcore/baselines/models/WRC.py
new file mode 100644
index 0000000..c496139
--- /dev/null
+++ b/batcore/baselines/models/WRC.py
@@ -0,0 +1,152 @@
+import ast
+import numpy as np
+from functools import partial
+from batcore.modelbase.recommender import BanRecommenderBase
+from ..utils import LCP, path2list
+from ray.util.multiprocessing import Pool
+import ray
+
+
+def count_score(f1, pull=None, wrc=None, files=None, users=None):
+    results = np.zeros(wrc.shape[1])
+    for f2 in pull['file']:
+        for i in range(wrc.shape[1]):
+            val = wrc[files.getid(f2), i]
+            if val >= 0:
+                results[i] += val * LCP(path2list(f1), path2list(f2))
+    return results
+
+
+class WRC(BanRecommenderBase):
+    """
+    WRC recommends reviewers based on the file path similarity measures. Files contribute to the final score not only
+    based on their similary but also on their recency and size of their pull request.
+
+    dataset - StandardDataset(data, user_items=True, file_items=True)
+
+    Paper: `Automatically Recommending Code Reviewers Based on Their Expertise: An Empirical Comparison <https://dl.acm.org/doi/abs/10.1145/2970276.2970306>`_
+
+    :param items2ids: dict with user2id and file2id
+    :param delta: time decay factor for weight of the previous reviews
+    :param no_owner: flag to add or remove owners of the pull request from the recommendations
+    :param no_inactive: flag to add or remove inactive reviewers from recommendations
+    :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
+    """
+
+    def __init__(self,
+                 items2ids,
+                 delta=1,
+                 no_owner=True,
+                 no_inactive=True,
+                 inactive_time=60):
+
+        super().__init__(no_owner, no_inactive, inactive_time)
+
+        self.files = items2ids['files']
+        self.users = items2ids['users']
+
+        self.delta = delta
+        self.reviews = []
+
+        # matrix of wrc scores for (user, files) pairs
+        self.wrc = np.zeros((len(self.files), len(self.users)))
+
+        self.known_files = set()
+
+        # self.scores = {}
+        self.p = Pool(10)
+        self.pool_cnt = 0
+        self.lcp_results = -np.ones((len(self.files), len(self.files)))
+        self.log = []
+
+    def LCP_count(self, f1, f2):
+        f1_id = self.files.getid(f1)
+        f2_id = self.files.getid(f2)
+
+        if self.lcp_results[f1_id, f2_id] == -1:
+            self.lcp_results[f1_id, f2_id] = LCP(path2list(f1), path2list(f2))
+
+        return self.lcp_results[f1_id, f2_id]
+
+    def predict(self, pull, n=10):
+        """
+        counts sum of all wrc score for each possible reviewer and files in the pull
+
+        :param n: number of reviewers to recommend
+        """
+        scores = np.zeros(len(self.users))
+
+        # for f1 in self.known_files:
+        #     for f2 in pull['file_path']:
+        #         for i in range(self.wrc.shape[1]):
+        #             val = self.wrc[self.files.getid(f2), i]
+        #             if val >= 0:
+        #                 scores[i] += val * LCP(path2list(f1), path2list(f2))  # self.LCP_count(f1, f2)
+
+        # f = time.time()
+        # self.log.append(f-s)
+        # params = product(self.known_files, pull['file_path'])
+        # res = [count_score2(f1, f2, self.wrc, self.files) for f1, f2 in params]
+        # res = [count_score2(f1, f2, self.wrc, self.files) for f1, f2 in params]
+
+        # s = time.time()
+        # res = self.p.starmap(partial(count_score2, wrc=self.wrc, files=self.files),
+        #                      product(self.known_files, pull['file_path']))
+        #
+
+        res = self.p.map(partial(count_score, pull=pull, wrc=self.wrc, files=self.files, users=self.users),
+                         self.known_files)
+        # # f = time.time()
+        # # print(f - s)
+        res = np.vstack(res)
+        scores = res.sum(axis=0)
+
+        self.pool_cnt += 1
+        if self.pool_cnt == 20:
+            ray.shutdown()
+            ray.init()
+            self.p = Pool(10)
+            self.pool_cnt = 0
+
+        # params = product(self.known_files, pull['file_path'], range(self.wrc.shape[1]))
+        # for (_), r in zip(params, res):
+        #     for i, v in zip(range(self.wrc.shape[1]), r):
+        #         scores[i] += v
+
+        scores = {self.users[i]: scores[i] for i in range(len(self.users))}
+        self.filter(scores, pull)
+        sorted_users = sorted(scores.keys(), key=lambda x: -scores[x])
+
+        return sorted_users[:n]
+
+    def fit(self, data):
+        """
+        updates wrc matrix
+        """
+        if self.no_inactive:
+            self.update_time(data)
+        for event in data:
+            if event['type'] == 'pull':
+                self.reviews.append(event)
+                self.wrc = self.delta * self.wrc
+
+                for file in event['file']:
+                    self.known_files.add(file)
+                    for user in event['reviewer']:
+                        self.wrc[self.files.getid(file), self.users.getid(user)] += 1 / len(event['reviewer'])
+
+    # def save(self, path='checkpoints'):
+    #     with open(f"{path}/wrc/wrc.npy", 'wb') as f:
+    #         np.save(f, self.wrc)
+    #     with open(f"{path}/wrc/lcp.npy", 'wb') as f:
+    #         np.save(f, self.lcp_results)
+    #     with open(f"{path}/wrc/files.npy", 'w') as f:
+    #         f.write(str(self.known_files))
+    #
+    # def load(self, path='checkpoints/wrc'):
+    #     with open(f"{path}/wrc.npy", 'rb') as f:
+    #         self.wrc = np.load(f)
+    #     with open(f"{path}/lcp.npy", 'rb') as f:
+    #         self.lcp_results = np.load(f)
+    #     with open(f"{path}/files.npy", 'r') as f:
+    #         self.known_files = ast.literal_eval(f.read())
diff --git a/Counter/__init__.py b/batcore/baselines/models/__init__.py
similarity index 100%
rename from Counter/__init__.py
rename to batcore/baselines/models/__init__.py
diff --git a/baselines/models/cHRev.py b/batcore/baselines/models/cHRev.py
similarity index 80%
rename from baselines/models/cHRev.py
rename to batcore/baselines/models/cHRev.py
index 6b8bb3b..e3e0d77 100644
--- a/baselines/models/cHRev.py
+++ b/batcore/baselines/models/cHRev.py
@@ -1,7 +1,7 @@
 from collections import defaultdict
 from datetime import datetime
 
-from RecommenderBase.recommender import BanRecommenderBase
+from batcore.modelbase.recommender import BanRecommenderBase
 
 
 class cHRev(BanRecommenderBase):
@@ -9,20 +9,18 @@ class cHRev(BanRecommenderBase):
     cHRev recommends candidates based on their commenting history. For this xFactor is calculated which measures
     relative portion and time recency of comments done by a candidate to the files
 
-    dataset - StandardDataset(data, comments=True)
+    Paper: `Automatically Recommending Peer Reviewers in Modern Code Review <https://ieeexplore.ieee.org/document/7328331>`_
 
-    Paper: "Automatically Recommending Peer Reviewers in Modern Code Review"
+    :param no_owner: flag to add or remove owners of the pull request from the recommendations
+    :param no_inactive: flag to add or remove inactive reviewers from recommendations
+    :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
     """
 
     def __init__(self,
                  no_owner=True,
                  no_inactive=True,
                  inactive_time=60):
-        """
-        :param no_owner: flag to add or remove owners of the pull request from the recommendations
-        :param no_inactive: flag to add or remove inactive reviewers from recommendations
-        :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
-        """
+
         super().__init__(no_owner, no_inactive, inactive_time)
 
         # reviewer expertise map. dict with (file, commenter)-triplet relation
@@ -41,10 +39,13 @@ def __init__(self,
     def predict(self, pull, n=10):
         """
         scoring of candidates is performed by xFactor (equation 1-2 from the paper)
-        :return: predicts at most :param n: reviewers for the :param pull:
+
+        :param pull: pull requests for which reviewers are required
+        :param n: number of reviewers to recommend
+        :return: at most n reviewers for the pull request
         """
         scores = defaultdict(lambda: 0)
-        for file in pull['file_path']:
+        for file in pull['file']:
             file_val = self.fr[file]
             for user in self.re[file]:
                 user_val = self.re[file][user]
diff --git a/baselines/models/xFinder.py b/batcore/baselines/models/xFinder.py
similarity index 82%
rename from baselines/models/xFinder.py
rename to batcore/baselines/models/xFinder.py
index 8ad73d6..7806ee8 100644
--- a/baselines/models/xFinder.py
+++ b/batcore/baselines/models/xFinder.py
@@ -3,7 +3,7 @@
 
 import numpy as np
 
-from RecommenderBase.recommender import BanRecommenderBase
+from batcore.modelbase.recommender import BanRecommenderBase
 
 
 class xFinder(BanRecommenderBase):
@@ -14,18 +14,18 @@ class xFinder(BanRecommenderBase):
 
     dataset - StandardDataset(data, commits=True)
 
-    Paper: "Assigning change requests to software developers"
+    Paper: `Assigning change requests to software developers <https://onlinelibrary.wiley.com/doi/abs/10.1002/smr.530>`_
+
+    :param no_owner: flag to add or remove owners of the pull request from the recommendations
+    :param no_inactive: flag to add or remove inactive reviewers from recommendations
+    :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
     """
 
     def __init__(self,
                  no_owner=True,
                  no_inactive=True,
                  inactive_time=60):
-        """
-        :param no_owner: flag to add or remove owners of the pull request from the recommendations
-        :param no_inactive: flag to add or remove inactive reviewers from recommendations
-        :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
-        """
+
         super().__init__(no_owner, no_inactive, inactive_time)
 
         # developer-coder map. dict with keys (file, developer) and triplet values
@@ -50,11 +50,14 @@ def __init__(self,
 
     def predict(self, pull, n=10):
         """
-       scoring of candidates is performed by xFactor (equations from page 8)
-       :return: predicts at most :param n: reviewers for the :param pull:
-       """
+        scoring of candidates is performed by xFactor (equations from page 8)
+
+        :param pull: pull requests for which reviwers are required
+        :param n: number of reviewers to recommend
+        :return: at most n reviewers for the pull request
+        """
         scores = defaultdict(lambda: 0)
-        for file in pull['file_path']:
+        for file in pull['file']:
             file_val = self.fv[file]
             for user in self.dv[file]:
                 user_val = self.dv[file][user]
diff --git a/baselines/utils.py b/batcore/baselines/utils.py
similarity index 97%
rename from baselines/utils.py
rename to batcore/baselines/utils.py
index 8f0fcde..76be096 100644
--- a/baselines/utils.py
+++ b/batcore/baselines/utils.py
@@ -6,8 +6,8 @@ def pull_sim(pull1, pull2):
     """
     counts file path-based similarity for pull1 and pull2
     """
-    changed_files1 = pull1["file_path"]
-    changed_files2 = pull2["file_path"]
+    changed_files1 = pull1["file"]
+    changed_files2 = pull2["file"]
     if len(changed_files1) == 0 or len(changed_files2) == 0:
         return 0
     sum_score = 0
diff --git a/batcore/bat_logging/__init__.py b/batcore/bat_logging/__init__.py
new file mode 100644
index 0000000..3644a16
--- /dev/null
+++ b/batcore/bat_logging/__init__.py
@@ -0,0 +1,6 @@
+from .loggers import *
+
+__all__ = [
+    "Logger",
+    "tester_logging",
+]
diff --git a/batcore/bat_logging/loggers.py b/batcore/bat_logging/loggers.py
new file mode 100644
index 0000000..48981d2
--- /dev/null
+++ b/batcore/bat_logging/loggers.py
@@ -0,0 +1,72 @@
+import logging
+import sys
+
+
+class Logger:
+    def setup_logger(self, verbose=False, log_file_path=None, log_stdout=None, log_mode='a'):
+        self.verbose = verbose
+        if self.verbose:
+            if (log_file_path is None) and (not log_stdout):
+                raise ValueError("When verbose=True you need to specify log_file_path or set log_stdout to True")
+
+            handlers = []
+            formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+
+            if log_file_path is not None:
+                file_handler = logging.FileHandler(log_file_path, mode=log_mode)
+                file_handler.setFormatter(formatter)
+                handlers.append(file_handler)
+            if log_stdout:
+                stdout_handler = logging.StreamHandler(sys.stdout)
+                stdout_handler.setFormatter(formatter)
+                handlers.append(stdout_handler)
+
+            self.logger = logging.getLogger(type(self).__name__)
+            for handler in handlers:
+                self.logger.addHandler(handler)
+
+            self.logger.setLevel(logging.INFO)
+
+    def info(self, msg):
+        if self.verbose:
+            self.logger.info(msg)
+
+    def exception(self, msg):
+        if self.verbose:
+            self.logger.exception(msg)
+
+    def warning(self, msg):
+        if self.verbose:
+            self.logger.warning(msg)
+
+    def error(self, msg):
+        if self.verbose:
+            self.logger.error(msg)
+
+    def debug(self, msg):
+        if self.verbose:
+            self.logger.debug(msg)
+
+
+def tester_logging(f):
+    def wrapper(self,
+                recommender,
+                data_iterator,
+                verbose=False,
+                log_file_path=None,
+                log_stdout=False,
+                *args, **kwargs):
+        self.setup_logger(verbose, log_file_path, log_stdout)
+        try:
+            return f(self,
+                     recommender,
+                     data_iterator,
+                     verbose=False,
+                     log_file_path=None,
+                     log_stdout=False,
+                     *args, **kwargs)
+        except Exception as e:
+            self.exception(f'got an exception {e}')
+            raise
+
+    return wrapper
diff --git a/Counter/BusFactorCounter.py b/batcore/counter/BusFactorCounter.py
similarity index 98%
rename from Counter/BusFactorCounter.py
rename to batcore/counter/BusFactorCounter.py
index 35e5d6d..193ba8f 100644
--- a/Counter/BusFactorCounter.py
+++ b/batcore/counter/BusFactorCounter.py
@@ -3,7 +3,7 @@
 
 import numpy as np
 
-from Counter.CounterBase import CounterBase
+from batcore.counter.CounterBase import CounterBase
 
 
 class FileInfo:
diff --git a/batcore/counter/CoreWorkloadCounter.py b/batcore/counter/CoreWorkloadCounter.py
new file mode 100644
index 0000000..8eca697
--- /dev/null
+++ b/batcore/counter/CoreWorkloadCounter.py
@@ -0,0 +1,37 @@
+from collections import defaultdict
+
+from batcore.counter.CounterBase import CounterBase
+
+
+class CoreWorkloadCounter(CounterBase):
+
+    def __call__(self, history, from_date=None, to_date=None):
+
+        """
+        Core Workload estimates amount of work for core developers. Core Workload equals to the total number of reviews
+        performed by 10 reviews that reviewed the most
+
+        :param history: data with reviews
+        :param from_date: start of the period on which CoreWorkload is calculated. If None starts from the start of history
+        :param to_date: end of the period on which CoreWorkload is calculated. If None ends at the end of history
+        :return: Core Workload metrics
+        """
+        if from_date is None:
+            from_date = history[0]['date']
+        if to_date is None:
+            to_date = history[-1]['date']
+
+        review_count = defaultdict(lambda: 0)
+        for pull in history:
+            if pull['date'] < from_date:
+                continue
+            if pull['date'] > to_date:
+                break
+
+            for reviewer in pull['reviewer']:
+                review_count[reviewer] += 1
+
+        sorted_counts = sorted(review_count.values(), key=lambda x: -x)
+        core_workload = sum(sorted_counts[:10])
+
+        return core_workload
diff --git a/Counter/CounterBase.py b/batcore/counter/CounterBase.py
similarity index 78%
rename from Counter/CounterBase.py
rename to batcore/counter/CounterBase.py
index e9164b9..c155c9a 100644
--- a/Counter/CounterBase.py
+++ b/batcore/counter/CounterBase.py
@@ -4,10 +4,10 @@
 class CounterBase(ABC):
     """
     Base class for non-recommendation metrics with a history simulation
+
+    :param history: historical data
     """
     @abstractmethod
     def __call__(self, history, *args, **kwargs):
-        """
-        :param history: historical data
-        """
+
         pass
diff --git a/Counter/ExpertiseCounter.py b/batcore/counter/ExpertiseCounter.py
similarity index 60%
rename from Counter/ExpertiseCounter.py
rename to batcore/counter/ExpertiseCounter.py
index 2cd5f3e..c8de730 100644
--- a/Counter/ExpertiseCounter.py
+++ b/batcore/counter/ExpertiseCounter.py
@@ -1,6 +1,7 @@
+import copy
 from collections import defaultdict
-
-from Counter.CounterBase import CounterBase
+from itertools import chain
+from batcore.counter.CounterBase import CounterBase
 
 
 class ExpertiseCounter(CounterBase):
@@ -22,13 +23,13 @@ def prepare(self):
 
         for event in self.data:
             if event['type'] == 'pull':
-                for file in event['file_path']:
-                    for reviewer in event['reviewer_login']:
-                        if reviewer not in self.when_known[file]:
-                            self.when_known[file][reviewer] = event['date']
+                for file in event['file']:
+                    for user in chain(event['owner'], event['author']):
+                        if user not in self.when_known[file]:
+                            self.when_known[file][user] = event['date']
                         else:
-                            prev = self.when_known[file][reviewer]
-                            self.when_known[file][reviewer] = min(event['date'], prev)
+                            prev = self.when_known[file][user]
+                            self.when_known[file][user] = min(event['date'], prev)
             elif event['type'] == 'commit' or event['type'] == 'comment':
                 file = event['key_file']
                 user = event['key_user']
@@ -52,6 +53,16 @@ def __call__(self, history, from_date=None, to_date=None):
         if to_date is None:
             to_date = history[-1]['date']
 
+        when_known = copy.deepcopy(self.when_known)
+        for event in history:
+            if event['type'] == 'pull':
+                for file in event['file']:
+                    for user in event['reviewer']:
+                        if user not in when_known[file]:
+                            when_known[file][user] = event['date']
+                        else:
+                            prev = when_known[file][user]
+                            when_known[file][user] = min(event['date'], prev)
         expertise = 0
         cnt = 0
         for pull in history:
@@ -62,18 +73,18 @@ def __call__(self, history, from_date=None, to_date=None):
 
             cnt += 1
             files_known = 0
-            if len(pull['file_path']) == 0:
+            if len(pull['file']) == 0:
                 continue
 
-            for file in pull['file_path']:
-                if file not in self.when_known:
+            for file in pull['file']:
+                if file not in when_known:
                     continue
-                for reviewer in pull['reviewer_login']:
-                    if reviewer not in self.when_known[file]:
+                for reviewer in pull['reviewer']:
+                    if reviewer not in when_known[file]:
                         continue
-                    if self.when_known[file][reviewer] < pull['date']:
+                    if when_known[file][reviewer] < pull['date']:
                         files_known += 1
                         break
-            expertise += files_known / len(pull['file_path'])
+            expertise += files_known / len(pull['file'])
 
         return expertise
diff --git a/Counter/FaRCounter.py b/batcore/counter/FaRCounter.py
similarity index 69%
rename from Counter/FaRCounter.py
rename to batcore/counter/FaRCounter.py
index 2ca5b88..602ef15 100644
--- a/Counter/FaRCounter.py
+++ b/batcore/counter/FaRCounter.py
@@ -1,7 +1,9 @@
+import copy
 from collections import defaultdict
 from itertools import chain
+from datetime import timedelta
 
-from Counter.CounterBase import CounterBase
+from batcore.counter.CounterBase import CounterBase
 
 
 class FaRCounter(CounterBase):
@@ -17,8 +19,6 @@ def __init__(self, iterator):
         self.data = iterator.data
         self.prepare()
 
-        self.wkr = []
-
     def prepare(self):
         """
         supporting calculation for the faster metric estimation
@@ -26,19 +26,18 @@ def prepare(self):
 
         for event in self.data:
             if event['type'] == 'pull':
-                for user in chain(event['reviewer_login'], event['owner']):
+                for user in chain(event['author'], event['owner']):
                     if user not in self.when_left:
-                        self.when_left[user] = event['date']
+                        self.when_left[user] = event['date'] + timedelta(30)
                     else:
-                        self.when_left[user] = max(self.when_left[user], event['date'])
+                        self.when_left[user] = max(self.when_left[user], event['date'] + timedelta(30))
 
-                for file in event['file_path']:
+                for file in event['file']:
                     if file not in self.when_created:
                         self.when_created[file] = event['date']
                     else:
                         self.when_created[file] = min(self.when_created[file], event['date'])
 
-
             elif event['type'] == 'commit' or event['type'] == 'comment':
                 file = event['key_file']
                 user = event['key_user']
@@ -55,9 +54,9 @@ def prepare(self):
                     self.when_created[file] = min(self.when_created[file], event['date'])
 
                 if user not in self.when_left:
-                    self.when_left[user] = event['date']
+                    self.when_left[user] = event['date'] + timedelta(30)
                 else:
-                    self.when_left[user] = max(self.when_left[user], event['date'])
+                    self.when_left[user] = max(self.when_left[user], event['date'] + timedelta(30))
 
     def __call__(self, history, from_date=None, to_date=None):
         """
@@ -73,37 +72,37 @@ def __call__(self, history, from_date=None, to_date=None):
         if to_date is None:
             to_date = history[-1]['date']
 
-        when_known_rev = defaultdict(lambda: {})
+        when_known = copy.deepcopy(self.when_known)
+        when_left = copy.deepcopy(self.when_left)
+
+        # when_known_rev = defaultdict(lambda: {})
+
         for pull in history:
-            for file in pull['file_path']:
-                for reviewer in pull['reviewer_login']:
-                    if reviewer not in when_known_rev[file]:
-                        when_known_rev[file][reviewer] = pull['date']
+            for reviewer in pull['reviewer']:
+                for file in pull['file']:
+                    if reviewer not in when_known[file]:
+                        when_known[file][reviewer] = pull['date']
                     else:
-                        prev = when_known_rev[file][reviewer]
-                        when_known_rev[file][reviewer] = min(pull['date'], prev)
+                        prev = when_known[file][reviewer]
+                        when_known[file][reviewer] = min(pull['date'], prev)
+
+                if reviewer not in when_left:
+                    when_left[reviewer] = pull['date'] + timedelta(30)
+                else:
+                    when_left[reviewer] = max(when_left[reviewer], pull['date'] + timedelta(30))
 
         active_dev = {}
+
         for file in self.when_created:
             if self.when_created[file] > to_date:
                 continue
             if file not in active_dev:
                 active_dev[file] = set()
-            for user in self.when_known[file]:
-                if self.when_left[user] >= to_date > self.when_known[file][user]:
+            for user in when_known[file]:
+                if when_left[user] >= to_date > when_known[file][user]:
                     active_dev[file].add(user)
 
-            for user in when_known_rev[file]:
-                try:
-                    if self.when_left[user] >= to_date > when_known_rev[file][user]:
-                        active_dev[file].add(user)
-                except Exception as e:
-                    print(file, user)
-                    raise e
-
         active_dev = {file: len(active_dev[file]) for file in active_dev}
         far = len([file for file in active_dev if active_dev[file] <= 1])
 
-        self.wkr.append(when_known_rev)
-
         return far  # , active_dev
diff --git a/batcore/counter/__init__.py b/batcore/counter/__init__.py
new file mode 100644
index 0000000..1570a8a
--- /dev/null
+++ b/batcore/counter/__init__.py
@@ -0,0 +1,8 @@
+from .CoreWorkloadCounter import CoreWorkloadCounter
+from .ExpertiseCounter import ExpertiseCounter
+from .FaRCounter import FaRCounter
+from .CounterBase import CounterBase
+
+__all__ = [
+    "CoreWorkloadCounter", "ExpertiseCounter", "FaRCounter", "CounterBase"
+]
diff --git a/data/DataLoader.py b/batcore/data/DataLoader.py
similarity index 69%
rename from data/DataLoader.py
rename to batcore/data/DataLoader.py
index 13d22b9..5b8f8a8 100644
--- a/data/DataLoader.py
+++ b/batcore/data/DataLoader.py
@@ -6,7 +6,7 @@
 
 class LoaderBase(ABC):
     """
-    Separate data iterator class that encapsulates
+    Separate data iterator class that encapsulates iteration logic
     """
 
     def __init__(self, dataset):
@@ -28,7 +28,11 @@ class StreamLoaderBase(LoaderBase, ABC):
 
     def __init__(self, dataset):
         super().__init__(dataset)
-        self.data = deepcopy(dataset.data)
+        data = []
+        for event_type in dataset.data:
+            data += dataset.data[event_type].to_dict('records')
+        data = sorted(data, key=lambda x: x['date'])
+        self.data = deepcopy(data)
 
     def __iter__(self):
         self.ind = 0
@@ -58,14 +62,17 @@ def get_next(self):
         pass
 
     def replace(self, rev):
-        l = len(self.data[self.ind + 1]['reviewer_login'])
-        self.data[self.ind + 1]['reviewer_login'] = deepcopy(self.data[self.ind + 1]['reviewer_login'])
-        self.data[self.ind + 1]['reviewer_login'][np.random.randint(l)] = rev
+        l = len(self.data[self.ind + 1]['reviewer'])
+        self.data[self.ind + 1]['reviewer'] = deepcopy(self.data[self.ind + 1]['reviewer'])
+        self.data[self.ind + 1]['reviewer'][np.random.randint(l)] = rev
 
         return self.data[self.ind + 1]
 
 
 class StreamUntilConditionLoader(StreamLoaderBase):
+    """
+         Stream iterator that iterates until specified amount of events satisfying a condition is met
+     """
     def __init__(self, dataset, condition, batch_size=1):
         super().__init__(dataset)
         self.condition = condition
@@ -95,7 +102,8 @@ def replace(self, rev):
 
 class PullLoader(StreamUntilConditionLoader):
     """
-        Stream iterator that iterates until specified amount of events of the certain type are encountered
+    Stream iterator that iterates over the data and returns batches with batch_size pull requests with at least one
+    reviewer (and all other events that it encountered)
     """
 
     def __init__(self, dataset, batch_size=1):
@@ -103,12 +111,13 @@ def __init__(self, dataset, batch_size=1):
 
     @staticmethod
     def _condition(pull):
-        return pull['type'] == 'pull' and (len(pull['reviewer_login']) > 0)
+        return pull['type'] == 'pull' and (len(pull['reviewer']) > 0)
 
 
 class PullLoaderAliasTest(StreamUntilConditionLoader):
     """
-        Stream iterator that iterates until specified amount of events of the certain type are encountered
+    Stream iterator that iterates over the data and returns batches with batch_size pull requests with at least one
+    that is not a self review reviewer (and all other events that it encountered)
     """
 
     def __init__(self, dataset, batch_size=1):
@@ -116,4 +125,4 @@ def __init__(self, dataset, batch_size=1):
 
     @staticmethod
     def _condition(pull):
-        return pull['type'] == 'pull' and not pull['self_review'] and (len(pull['reviewer_login']) > 0)
+        return pull['type'] == 'pull' and not pull['self_review'] and (len(pull['reviewer']) > 0)
diff --git a/data/DatasetBase.py b/batcore/data/DatasetBase.py
similarity index 100%
rename from data/DatasetBase.py
rename to batcore/data/DatasetBase.py
diff --git a/data/GerritLoader.py b/batcore/data/MRLoaderData.py
similarity index 57%
rename from data/GerritLoader.py
rename to batcore/data/MRLoaderData.py
index 0830cf0..e9fd4d9 100644
--- a/data/GerritLoader.py
+++ b/batcore/data/MRLoaderData.py
@@ -4,73 +4,51 @@
 import numpy as np
 import pandas as pd
 
-from AliasMatching.utils import get_clusters
-from data.utils import time_interval, user_id_split, is_bot
+from batcore.bat_logging import Logger
+from batcore.data.utils import time_interval
 
+from datetime import datetime
 
-class GerritLoader:
+
+class MRLoaderData(Logger):
     """
-       Helping dataset class for the gerrit-based projects. It reads data in the format that is provided with our
-       download script and outputs in a comfortable format
-       """
+        Helping dataset class for the gerrit-based projects. It reads data in the format that is provided with our
+        download script and outputs in a comfortable format
 
-    def __init__(self, path, from_date=None, to_date=None,
-                 from_checkpoint=False,
-                 process_users=False,
-                 factorize_users=True, alias=False,
-                 remove_bots=True, bots='auto',
-                 project_name=''):
-        """
         :param path: path to the folder with the data from the loader tool or to the saved dataset
         :param from_date: all events before from_date are removed from the data
+        :type from_date: datetime.datetime
         :param to_date: all events after to_date are removed from the data
-        :param from_checkpoint: set to True to load saved dataset
-        :param factorize_users: when true users are replaced by id
-        :param alias: True if clustering of the users by name should be performed
-        :param bots: strategy for bot identification in user factorization. When 'auto' bots will be determined
-        automatically. Otherwise, path to the csv with bot accounts should be specified
-        :param project_name: name of the project for automatic bot detection
-        """
-        if from_checkpoint:
-            self.pulls = pd.read_csv(path + '/pulls.csv', index_col=0)
-            self.pulls.date = pd.to_datetime(self.pulls.date).dt.tz_localize(None)
-
-            self.pulls.file_path = self.pulls.file_path.apply(ast.literal_eval)
-            self.pulls.reviewer_login = self.pulls.reviewer_login.apply(ast.literal_eval)
-
-            self.pulls.owner = self.pulls.owner.apply(lambda x: ast.literal_eval(x) if x is not np.nan else [])
-            self.pulls.author = self.pulls.author.apply(lambda x: ast.literal_eval(x) if x is not np.nan else [])
-
-            self.pulls = self.pulls.fillna('')
-
-            try:
-                self.pulls.reviewer_login = self.pulls.reviewer_login.apply(lambda x: [int(i) for i in x])
-                self.pulls.author = self.pulls.author.apply(
-                    lambda x: [int(i) for i in x])
-            except ValueError:
-                pass
-
-            self.commits = pd.read_csv(path + '/commits.csv', index_col=0)
-            self.commits.date = pd.to_datetime(self.commits.date).dt.tz_localize(None)
-            self.comments = pd.read_csv(path + '/comments.csv', index_col=0)
-            self.comments.date = pd.to_datetime(self.comments.date).dt.tz_localize(None)
-        else:
+        :type to_date: datetime.datetime
+       """
 
+    def __init__(self,
+                 path=None,
+                 from_date=None,
+                 to_date=None,
+                 verbose=False,
+                 log_file_path=None,
+                 log_stdout=False,
+                 log_mode='a'
+                 ):
+
+        self.setup_logger(verbose, log_file_path, log_stdout, log_mode)
+        if path is not None:
             self.from_date = from_date
             self.to_date = to_date
 
-            self.factorize = factorize_users
-
-            data = GerritLoader.get_df(path)
+            self.info(f"loading gerrit data from {path}")
+            data = MRLoaderData.get_df(path)
+            self.info(f"processing data")
             self.pulls, self.commits, self.comments = self.prepare(data)
+            self.info(f"additional processing for the pull request")
             self.prepare_pulls()
-
-            if process_users:
-                self.prepare_users(remove_bots, bots, factorize_users, alias, project_name)
+            self.info(f"finished processing the data")
 
     @staticmethod
     def get_df(path):
         """
+        reading all the csv files from MR-loader
         :param path: path to the directory with csv files
         :return: dictionary with all dataframes for pulls, commits, and comments
         """
@@ -90,6 +68,7 @@ def get_df(path):
 
     def prepare(self, data):
         """
+        processing the data
         :param data: dictionary with all dataframes
         :return: pulls and commits dataframes with all mined features
         """
@@ -114,7 +93,7 @@ def prepare(self, data):
         # remove some columns
         commits = commits.drop(
             ['oid', 'index_x', 'index_y', 'index', 'committed_date', 'lines_inserted', 'lines_deleted', 'size',
-             'size_delta', 'uploader_key_user', 'committer_key_user'], axis=1)
+             'size_delta', 'uploader_key_user', 'committer_key_user', 'status'], axis=1)
         # re-format files paths
         commits['key_file'] = commits['key_file'].apply(lambda x: x.replace(':', '/'))
 
@@ -171,63 +150,8 @@ def prepare(self, data):
 
         comments = pd.concat((comments_pull, comments_file), axis=0).reset_index().drop(['index'], axis=1)
 
-        # users
         return pulls, commits, comments
 
-    def prepare_users(self, remove_bots, bots, factorize_users, alias, project_name, threshold=0.1):
-        u1 = pd.unique(self.pulls.owner.sum())
-        u2 = pd.unique(self.pulls.reviewer_login.sum())
-        u3 = pd.unique(self.commits.key_user)
-        u4 = pd.unique(self.comments.key_user)
-
-        users = np.unique(np.hstack((u1, u2, u3, u4)))
-
-        if remove_bots:
-            if bots != 'auto':
-                bots = pd.read_csv(bots).fillna('')
-                bots = bots.apply(lambda x: f'{x["name"]}:{x["email"]}:{x["login"]}', axis=1)
-                bots = set(bots)
-            else:
-                bots = set([u for u in users if is_bot(u, project_name)])
-
-            users = np.array([u for u in users if u not in bots])
-
-            self.pulls['owner'] = self.pulls['owner'].apply(lambda x: [u for u in x if u not in bots])
-            self.pulls['reviewer_login'] = self.pulls['reviewer_login'].apply(lambda x: [u for u in x if u not in bots])
-            self.pulls['author'] = self.pulls['author'].apply(lambda x: [u for u in x if u not in bots])
-
-            self.pulls = self.pulls[self.pulls.owner.apply(lambda x: len(x) > 0)]
-            self.pulls = self.pulls[self.pulls.reviewer_login.apply(lambda x: len(x) > 0)]
-
-            self.commits['key_user'] = self.commits['key_user'].apply(lambda x: np.nan if x in bots else x)
-            self.comments['key_user'] = self.comments['key_user'].apply(lambda x: np.nan if x in bots else x)
-
-            self.comments = self.comments[~self.comments.key_user.isna()]
-            self.commits = self.commits[~self.commits.key_user.isna()]
-
-        if factorize_users:
-            if alias:
-                users_parts = [user_id_split(s) for s in users]
-
-                users_df = pd.DataFrame({'email': [u[1] for u in users_parts],
-                                         'name': [u[0] for u in users_parts],
-                                         'login': [u[2] for u in users_parts],
-                                         'initial_id': [u for u in users]})
-                clusters = get_clusters(users_df, distance_threshold=threshold)
-            else:
-                clusters = {u: i for i, u in enumerate(users)}
-
-            self.clusters = clusters
-            self.pulls.owner = self.pulls.owner.apply(lambda x: [clusters[u] for u in x])
-            self.pulls.author = self.pulls.author.apply(lambda x: [clusters[u] for u in x])
-            self.pulls.reviewer_login = self.pulls.reviewer_login.apply(lambda x: [clusters[u] for u in x])
-            self.commits.key_user = self.commits.key_user.apply(lambda x: clusters[x])
-            self.comments.key_user = self.comments.key_user.apply(lambda x: clusters[x])
-
-            # self.pulls = self.pulls.dropna()
-            # self.comments = self.comments.dropna()
-            # self.commits = self.commits.dropna()
-
     def prepare_pulls(self):
         """
         pull preprocessing
@@ -237,13 +161,17 @@ def prepare_pulls(self):
         self.pulls = self.pulls[
             ['file_path', 'key_change', 'reviewer_login', 'created_at', 'owner', 'comment', 'status',
              'updated_at']].rename(
-            {'created_at': 'date', 'comment': 'title', 'updated_at': 'closed'}, axis=1)
+            {'created_at': 'date',
+             'comment': 'title',
+             'updated_at': 'closed',
+             'reviewer_login': 'reviewer',
+             'file_path': 'file'}, axis=1)
 
         # group entries by pulls and aggregates reviewers
         self.pulls = self.pulls.groupby('key_change')[
-            ['file_path', 'reviewer_login', 'date', 'owner', 'title', 'status', 'closed']].agg(
-            {'file_path': lambda x: list(set(x)),
-             'reviewer_login': lambda x: list(set(x)),
+            ['file', 'reviewer', 'date', 'owner', 'title', 'status', 'closed']].agg(
+            {'file': lambda x: list(set(x)),
+             'reviewer': lambda x: list(set(x)),
              'date': lambda x: list(x)[0],
              'owner': lambda x: list(set(x)),
              'title': lambda x: list(x)[0],
@@ -257,9 +185,43 @@ def prepare_pulls(self):
         pull_authors = pull_authors.rename({'key_user': 'author'}, axis=1)
         self.pulls = self.pulls.merge(pull_authors, on='key_change', how='left')
 
+    def from_checkpoint(self, path):
+        self.pulls = pd.read_csv(path + '/pulls.csv', index_col=0)
+        self.pulls.date = pd.to_datetime(self.pulls.date).dt.tz_localize(None)
+
+        self.pulls.file = self.pulls.file.apply(ast.literal_eval)
+        self.pulls.reviewer = self.pulls.reviewer.apply(ast.literal_eval)
+
+        self.pulls.owner = self.pulls.owner.apply(lambda x: ast.literal_eval(x) if x is not np.nan else [])
+        self.pulls.author = self.pulls.author.apply(lambda x: ast.literal_eval(x) if x is not np.nan else [])
+
+        self.pulls = self.pulls.fillna('')
+
+        try:
+            self.pulls.reviewer = self.pulls.reviewer.apply(lambda x: [int(i) for i in x])
+            self.pulls.author = self.pulls.author.apply(
+                lambda x: [int(i) for i in x])
+        except ValueError:
+            pass
+
+        if os.path.isfile(path + '/commits.csv'):
+            self.commits = pd.read_csv(path + '/commits.csv', index_col=0)
+            self.commits.date = pd.to_datetime(self.commits.date).dt.tz_localize(None)
+        else:
+            self.commits = pd.DataFrame(columns=['key_commit', 'key_change', 'key_file', 'status', 'key_user', 'date'])
+
+        if os.path.isfile(path + '/comments.csv'):
+            self.comments = pd.read_csv(path + '/comments.csv', index_col=0)
+            self.comments.date = pd.to_datetime(self.comments.date).dt.tz_localize(None)
+        else:
+            self.commits = pd.DataFrame(columns=['key_user', 'key_change', 'date', 'key_file'])
+
+        return self
+
     def to_checkpoint(self, path):
         """
         saves dataset
+
         :param path: path to the folder to save results
         """
 
diff --git a/data/SpecialDatasets.py b/batcore/data/SpecialDatasets.py
similarity index 59%
rename from data/SpecialDatasets.py
rename to batcore/data/SpecialDatasets.py
index 7745f65..0577f80 100644
--- a/data/SpecialDatasets.py
+++ b/batcore/data/SpecialDatasets.py
@@ -1,12 +1,16 @@
 import pandas as pd
 
-from data.StandardDataset import StandardDataset
+from batcore.data.StandardDataset import StandardDataset
 from .utils import ItemMap
 from .utils import get_all_reviewers, get_all_words
 
 
 class RevFinderDataset(StandardDataset):
-    def additional_preprocessing(self, events, data):
+    def additional_preprocessing(self, events):
+        data = []
+        for event_type in events:
+            data += events[event_type].to_dict('records')
+        data = sorted(data, key=lambda x: x['date'])
         self.reviewers = get_all_reviewers(data)
 
     def get_items2ids(self):
@@ -15,11 +19,15 @@ def get_items2ids(self):
 
 
 class RevRecDataset(StandardDataset):
-    def additional_preprocessing(self, events, data):
+    def additional_preprocessing(self, events):
         self.users = ItemMap()
+        data = []
+        for event_type in events:
+            data += events[event_type].to_dict('records')
+        data = sorted(data, key=lambda x: x['date'])
         for event in data:
             if event['type'] == 'pull':
-                for rev in event['reviewer_login']:
+                for rev in event['reviewer']:
                     self.users.add2(rev)
         for user in pd.unique(events['pulls']['owner'].sum()):
             self.users.add2(user)
@@ -33,7 +41,12 @@ def additional_preprocessing(self, events, data):
 
 
 class TieDataset(StandardDataset):
-    def additional_preprocessing(self, events, data):
+    def additional_preprocessing(self, events):
+        data = []
+        for event_type in events:
+            data += events[event_type].to_dict('records')
+        data = sorted(data, key=lambda x: x['date'])
+
         self.reviewers = get_all_reviewers(data)
         self.words = get_all_words(data)
 
diff --git a/batcore/data/StandardDataset.py b/batcore/data/StandardDataset.py
new file mode 100644
index 0000000..2c308ab
--- /dev/null
+++ b/batcore/data/StandardDataset.py
@@ -0,0 +1,310 @@
+import os
+from copy import deepcopy
+
+import numpy as np
+import pandas as pd
+
+from batcore.bat_logging import Logger
+from batcore.data.DatasetBase import DatasetBase
+from batcore.data.utils import ItemMap, preprocess_users, add_self_review
+import ast
+
+
+class StandardDataset(DatasetBase, Logger):
+    """
+    dataset for most of the implemented models.
+
+    :param dataset: GerritLoader-like object
+    :param max_file: maximum number of files that a review can have
+    :param commits: if False commits are omitted from the data
+    :param comments: if False comments are omitted from the data
+    :param user_items: if True user2id map is created
+    :param file_items: if True file2id map is created
+    :param pull_items: if true pull2id map is created
+    :param owner_policy: how pull owners are calculated.
+        * None - owners are unchanged
+        * author - commit authors of the pull are treated as owners
+        * author_no_na - commit authors of the pull are treated as owners. pulls without an author are removed
+        * author_owner_fallback - if pull has author, owner field set to the author. Otherwise, nothing is done
+    :param remove: list of columns to remove from the reviewers. Can be a subset of ['owner', 'author']
+    :param factorize_users: when true users are replaced by id
+    :param alias: True if clustering of the users by name should be performed
+    :param bots: strategy for bot identification in user factorization. When 'auto' bots will be determined automatically. Otherwise, path to the csv with bot accounts should be specified
+    :param project_name: name of the project for automatic bot detection
+    :param self_review_flag: when true adds a column to the pulls dataframe which signifies that there was a self-review (based on the users aliases)
+    """
+
+    def __init__(self,
+                 dataset=None,
+                 max_file=86,
+                 commits=False,
+                 comments=False,
+                 user_items=False,
+                 file_items=False,
+                 pull_items=False,
+                 remove_empty=False,
+                 owner_policy='author_owner_fallback',
+                 remove='none',
+                 process_users=False,
+                 factorize_users=True,
+                 alias=False,
+                 remove_bots=True,
+                 bots='auto',
+                 project_name='',
+                 self_review_flag=False,
+                 checkpoint_path=None,
+                 verbose=False,
+                 log_file_path=None,
+                 log_stdout=False,
+                 log_mode='a'
+                 ):
+
+        self.setup_logger(verbose, log_file_path, log_stdout, log_mode)
+
+        self.checkpoint = checkpoint_path is not None
+        self.bad_pulls = None
+        self.max_file = max_file
+        self.commits = commits
+        self.comments = comments
+
+        if checkpoint_path is not None:
+            self.info(f'loading from checkpoint {checkpoint_path}')
+            self.from_checkpoint(checkpoint_path)
+            self.info(f'finished loading from checkpoint {checkpoint_path}')
+        if remove == 'none':
+            remove = ['owner']
+
+        dataset = deepcopy(dataset)
+        if process_users:
+            self.info(f'starting processing users')
+            if self_review_flag:
+                dataset_alias = deepcopy(dataset)
+                preprocess_users(dataset_alias, remove_bots, bots, factorize_users, True, project_name, threshold=0.1)
+
+            if self_review_flag and alias:
+                dataset = dataset_alias
+            else:
+                preprocess_users(dataset, remove_bots, bots, factorize_users, alias, project_name, threshold=0.1)
+            if self_review_flag:
+                add_self_review(dataset_alias, dataset)
+            self.info(f'finished processing users')
+
+        self.user_items = user_items
+        if self.user_items:
+            self.users = None
+
+        self.file_items = file_items
+        if self.file_items:
+            self.files = None
+
+        self.pull_items = pull_items
+        if self.pull_items:
+            self.pulls = None
+
+        self.owner_policy = owner_policy
+        self.remove = remove
+        self.remove_empty = remove_empty
+
+        self.info(f'processing all data')
+        super().__init__(dataset)
+
+    def preprocess(self, dataset):
+        """
+        :param dataset: GerritLoader-like dataset
+        :return: preprocess all necessary events and returns them as data stream
+        """
+
+        if not self.checkpoint:
+            pulls = self.get_pulls(dataset)
+
+            events = {'pulls': pulls}
+            if self.commits:
+                commits = self.get_commits(dataset)
+                events['commits'] = commits
+
+            if self.comments:
+                comments = self.get_comments(dataset)
+                events['comments'] = comments
+        else:
+            events = self.data
+
+        self.additional_preprocessing(events)
+        return events
+
+    def get_pulls(self, dataset):
+        """
+        :param dataset: GerritLoader-like dataset
+        :return: preprocessed pulls dataframe
+        """
+        # remove opened pulls. only Merged and Abandoned stay
+        pulls = dataset.pulls[dataset.pulls.status != 'OPEN']
+        # remember big pulls
+        # self.bad_pulls = self.bad_pulls.union(set(pulls[pulls.file.apply(len) > self.max_file]['key_change']))
+        self.bad_pulls = set(pulls[pulls.file.apply(len) > self.max_file]['key_change'])
+
+        if self.remove_empty:
+            # remember pull w/out reviewers
+            self.bad_pulls = self.bad_pulls.union(set(pulls[pulls.reviewer.apply(len) == 0]['key_change']))
+
+        # remove big pulls and pull w/out reviewers
+        pulls = pulls[pulls.file.apply(len) <= self.max_file]
+        if self.remove_empty:
+            pulls = pulls[pulls.reviewer.apply(len) > 0]
+
+        # owner estimation
+        if self.owner_policy == 'author':
+            pulls.owner = pulls.author
+        elif self.owner_policy == 'author_no_na':
+            pulls.owner = pulls.author
+            pulls = pulls.dropna()
+        elif self.owner_policy == 'author_owner_fallback':
+            pulls.owner = pulls.apply(lambda x: x.author if len(x.author) else x.owner, axis=1)
+        # elif self.owner_policy == 'none':
+        #     pulls.owner = pulls.owner.apply(lambda x: [int(i) for i in x])
+        else:
+            raise ValueError(f'Wrong owner_policy {self.owner_policy}')
+
+        # pulls.reviewer = pulls.reviewer.apply(lambda x: [int(i) for i in x])
+
+        if len(self.remove):
+            for col in self.remove:
+                pulls.reviewer = pulls.apply(lambda x: [rev for rev in x['reviewer'] if rev not in x[col]],
+                                             axis=1)
+
+        # add label
+        pulls.loc[:, 'type'] = 'pull'
+
+        return pulls
+
+    def get_commits(self, dataset):
+        """
+        :param dataset: GerritLoader-like dataset
+        :return: preprocessed commits dataframe
+        """
+        commits = dataset.commits
+        # remove commits to the bad pulls
+        commits = commits[~commits['key_change'].isin(self.bad_pulls)]
+        if commits.shape[0] > 0:
+            commits.loc[:, 'type'] = 'commit'
+        return commits
+
+    def get_comments(self, dataset):
+        """
+        :param dataset: GerritLoader-like dataset
+        :return: preprocessed commits dataframe
+        """
+        comments = dataset.comments
+        # remove comments to the bad pulls
+        comments = comments[~comments['key_change'].isin(self.bad_pulls)]
+        # comments['type'] = 'comment'
+        if comments.shape[0] > 0:
+            comments.loc[:, 'type'] = 'comment'
+        return comments
+
+    def itemize_users(self, events):
+        """
+        creates user2id map from events
+        """
+        user_list = []
+        if 'pulls' in events:
+            pulls = events['pulls']
+            user_list += pulls['reviewer'].sum() + pulls['owner'].sum()
+        if 'comments' in events:
+            user_list += events['comments']['key_user'].to_list()
+        if 'commits' in events:
+            user_list += events['commits']['key_user'].to_list()
+        self.users = ItemMap(user_list)
+
+        # if 'pulls' in events:
+        # pulls = events['pulls']
+        # pulls['reviewer'] = pulls['reviewer'].apply(lambda x: [self.users.getid(u) for u in x])
+        # pulls['owner'] = pulls['owner'].apply(lambda x: [self.users.getid(u) for u in x])
+        # if 'comments' in events:
+        #     events['comments']['key_user'] = events['comments']['key_user'].apply(lambda x: self.users.getid(x))
+        # if 'commits' in events:
+        #     events['commits']['key_user'] = events['commits']['key_user'].apply(lambda x: self.users.getid(x))
+
+    def itemize_pulls(self, events):
+        """
+        creates pull2id map from events
+        """
+        self.pulls = ItemMap(events['pulls']['key_change'])
+
+    def itemize_files(self, events):
+        """
+        creates file2id map from events
+        """
+        self.files = ItemMap(events['pulls']['file'].sum())
+
+    def additional_preprocessing(self, events):
+        """
+        creates all item2id maps
+        """
+        if self.user_items:
+            self.itemize_users(events)
+        if self.pull_items:
+            self.itemize_pulls(events)
+        if self.file_items:
+            self.itemize_files(events)
+
+    def replace(self, data, rev):
+        data = deepcopy(data)
+        l = len(data['reviewer_list'])
+        data['reviewer_list'][np.random.randint(l)] = rev
+
+        return data
+
+    def get_items2ids(self):
+        ret = {}
+        if self.user_items:
+            ret['users'] = self.users
+        if self.pull_items:
+            ret['pulls'] = self.pulls
+        if self.file_items:
+            ret['files'] = self.files
+        return ret
+
+    def from_checkpoint(self, path):
+
+        self.data = {}
+        self.data['pulls'] = pd.read_csv(path + '/pulls.csv', index_col=0)
+
+        self.data['pulls'].date = pd.to_datetime(self.data['pulls'].date).dt.tz_localize(None)
+
+        self.data['pulls'].file = self.data['pulls'].file.apply(ast.literal_eval)
+        self.data['pulls'].reviewer = self.data['pulls'].reviewer.apply(ast.literal_eval)
+
+        self.data['pulls'].owner = self.data['pulls'].owner.apply(
+            lambda x: ast.literal_eval(x) if x is not np.nan else [])
+        self.data['pulls'].author = self.data['pulls'].author.apply(
+            lambda x: ast.literal_eval(x) if x is not np.nan else [])
+
+        self.data['pulls'] = self.data['pulls'].fillna('')
+
+        try:
+            self.data['pulls'].reviewer = self.data['pulls'].reviewer.apply(lambda x: [int(i) for i in x])
+            self.data['pulls'].author = self.data['pulls'].author.apply(lambda x: [int(i) for i in x])
+        except ValueError:
+            pass
+
+        if self.commits:
+            self.data['commits'] = pd.read_csv(path + '/commits.csv', index_col=0)
+            self.data['commits'].date = pd.to_datetime(self.data['commits'].date).dt.tz_localize(None)
+
+        if self.comments:
+            self.data['comments'] = pd.read_csv(path + '/comments.csv', index_col=0)
+            self.data['comments'].date = pd.to_datetime(self.data['comments'].date).dt.tz_localize(None)
+
+    def to_checkpoint(self, path):
+        """
+        saves dataset
+
+        :param path: path to the folder to save results
+        """
+
+        if not os.path.exists(path):
+            os.makedirs(path)
+
+        self.data['pulls'].to_csv(path + '/pulls.csv')
+        self.data['commits'].to_csv(path + '/commits.csv')
+        self.data['comments'].to_csv(path + '/comments.csv')
diff --git a/data/__init__.py b/batcore/data/__init__.py
similarity index 88%
rename from data/__init__.py
rename to batcore/data/__init__.py
index e159b52..b743dde 100644
--- a/data/__init__.py
+++ b/batcore/data/__init__.py
@@ -1,5 +1,5 @@
 from .DatasetBase import DatasetBase
-from .GerritLoader import GerritLoader
+from .MRLoaderData import MRLoaderData
 from .SpecialDatasets import RevRecDataset, RevFinderDataset, TieDataset
 from .StandardDataset import StandardDataset
 from .DataLoader import *
@@ -7,7 +7,7 @@
 
 __all__ = [
     "DatasetBase",
-    "GerritLoader",
+    "MRLoaderData",
     "RevRecDataset",
     "RevFinderDataset",
     "TieDataset",
diff --git a/data/aggregators.py b/batcore/data/aggregators.py
similarity index 96%
rename from data/aggregators.py
rename to batcore/data/aggregators.py
index 3edded9..f498cb3 100644
--- a/data/aggregators.py
+++ b/batcore/data/aggregators.py
@@ -1,7 +1,7 @@
 from copy import deepcopy
 
-from baselines import *
-from data import StandardDataset, RevRecDataset, TieDataset, RevFinderDataset
+from batcore.data import StandardDataset, RevRecDataset, TieDataset, RevFinderDataset
+from batcore.baselines import *
 
 default_args = {'max_file': 50,
                 'commits': False,
diff --git a/data/utils.py b/batcore/data/utils.py
similarity index 51%
rename from data/utils.py
rename to batcore/data/utils.py
index 9015c03..1a4f7ef 100644
--- a/data/utils.py
+++ b/batcore/data/utils.py
@@ -1,7 +1,12 @@
 import re
 import numpy as np
+import pandas as pd
 from nltk import LancasterStemmer
 
+
+from aliasmatching import BirdMatching
+# from batcore.alias.utils import get_clusters
+
 stemmer = LancasterStemmer()
 
 
@@ -61,7 +66,7 @@ def get_all_reviewers(events):
     reviewer_set = set()
     for event in events:
         if event['type'] == 'pull':
-            for reviewer in event["reviewer_login"]:
+            for reviewer in event["reviewer"]:
                 reviewer_set.add(reviewer)
     return list(reviewer_set)
 
@@ -141,3 +146,70 @@ def is_bot(x, project=''):
         return True
 
     return False
+
+
+def preprocess_users(data, remove_bots, bots, factorize_users, alias, project_name, threshold=0.1):
+    u1 = pd.unique(data.pulls.owner.sum())
+    u2 = pd.unique(data.pulls.reviewer.sum())
+    u3 = pd.unique(data.commits.key_user)
+    u4 = pd.unique(data.comments.key_user)
+    u5 = pd.unique(data.pulls.author.apply(lambda x: list(x)).sum())
+
+    users = np.unique(np.hstack((u1, u2, u3, u4, u5)))
+
+    if remove_bots:
+        if bots != 'auto':
+            bots = pd.read_csv(bots).fillna('')
+            bots = bots.apply(lambda x: f'{x["name"]}:{x["email"]}:{x["login"]}', axis=1)
+            bots = set(bots)
+        else:
+            bots = set([u for u in users if is_bot(u, project_name)])
+
+        users = np.array([u for u in users if u not in bots])
+
+        data.pulls['owner'] = data.pulls['owner'].apply(lambda x: [u for u in x if u not in bots])
+        data.pulls['reviewer'] = data.pulls['reviewer'].apply(lambda x: [u for u in x if u not in bots])
+        data.pulls['author'] = data.pulls['author'].apply(lambda x: [u for u in x if u not in bots])
+
+        data.pulls = data.pulls[data.pulls.owner.apply(lambda x: len(x) > 0)]
+        data.pulls = data.pulls[data.pulls.reviewer.apply(lambda x: len(x) > 0)]
+
+        data.commits['key_user'] = data.commits['key_user'].apply(lambda x: np.nan if x in bots else x)
+        data.comments['key_user'] = data.comments['key_user'].apply(lambda x: np.nan if x in bots else x)
+
+        data.comments = data.comments[~data.comments.key_user.isna()]
+        data.commits = data.commits[~data.commits.key_user.isna()]
+
+    if factorize_users:
+        if alias:
+            users_parts = [user_id_split(s) for s in users]
+
+            users_df = pd.DataFrame({'email': [u[1] for u in users_parts],
+                                     'name': [u[0] for u in users_parts],
+                                     'login': [u[2] for u in users_parts],
+                                     'initial_id': [u for u in users]})
+
+            clusters = BirdMatching(distance_threshold=threshold).get_clusters(users_df)
+        else:
+            clusters = {u: i for i, u in enumerate(users)}
+
+        data.clusters = clusters
+        data.pulls.owner = data.pulls.owner.apply(lambda x: list(set([clusters[u] for u in x])))
+        data.pulls.author = data.pulls.author.apply(lambda x: list(set([clusters[u] for u in x])))
+        data.pulls.reviewer = data.pulls.reviewer.apply(lambda x: list(set([clusters[u] for u in x])))
+
+        data.commits.key_user = data.commits.key_user.apply(lambda x: clusters[x])
+        data.comments.key_user = data.comments.key_user.apply(lambda x: clusters[x])
+
+        return clusters
+
+
+def add_self_review(data_a, data_f):
+    try:
+        data_f.pulls.drop('self_review', axis=1)
+    except KeyError:
+        pass
+
+    data_f.pulls['self_review'] = data_a.pulls.apply(lambda x:
+                                                     len(set(x.reviewer).intersection(set(x.author))) > 0,
+                                                     axis=1)
diff --git a/batcore/metrics/__init__.py b/batcore/metrics/__init__.py
new file mode 100644
index 0000000..495dcb9
--- /dev/null
+++ b/batcore/metrics/__init__.py
@@ -0,0 +1,12 @@
+from .metrics import *
+
+__all__ = [
+    "bootstrap_estimation",
+    "count_topk_metric",
+    "count_mrr",
+    "recall",
+    "precision",
+    "accuracy",
+    "f1score",
+    "count_metrics"
+]
diff --git a/Metrics/metrics.py b/batcore/metrics/metrics.py
similarity index 77%
rename from Metrics/metrics.py
rename to batcore/metrics/metrics.py
index ebb19cf..e34e130 100644
--- a/Metrics/metrics.py
+++ b/batcore/metrics/metrics.py
@@ -1,4 +1,5 @@
 import numpy as np
+import pandas as pd
 
 
 def count_confidence(sample):
@@ -43,17 +44,20 @@ def bootstrap_estimation(metric_vals, bootstrap_size=50, bootstrap_repeat=1000):
     return real_mean, bt_var
 
 
-def count_mrr(res):
+def count_mrr(gt, pred):
     """
-    :param res: pd.DataFrame with prediction done by the model. Should have column 'rev' that represents ground truth.
-    Also column 'top-10' that represents best 10 suggestions
+    Calculates `mean reciprocal rank (mrr) <https://en.wikipedia.org/wiki/Mean_reciprocal_rank>`_ of the given predictions wrt ground truth.
+
+    :param gt: ground truth
+    :param pred: predictions
     :return: mean and std of reciprocal ranks
     """
     rrs = []
-    for _, row in res.iterrows():
+    df = pd.DataFrame({'gt': gt, 'pred': pred})
+    for _, row in df.iterrows():
         rr = [np.inf]
-        for t in row['rev']:
-            rr = min(rr, 1 + np.where(np.array(row['top-10']) == t)[0])
+        for t in row['gt']:
+            rr = min(rr, 1 + np.where(np.array(row['pred']) == t)[0])
         rrs.append(1 / rr[0])
 
     return {'mrr': bootstrap_estimation(rrs)}
@@ -61,6 +65,8 @@ def count_mrr(res):
 
 def recall(gt, pred):
     """
+    Calculates `recall <https://en.wikipedia.org/wiki/Precision_and_recall>`_ of the given predictions wrt ground truth.
+
     :param gt: ground truth
     :param pred: predictions
     :return: Recall score
@@ -74,6 +80,9 @@ def recall(gt, pred):
 
 def precision(gt, pred):
     """
+    Calculates `precision <https://en.wikipedia.org/wiki/Accuracy_and_precision#In_binary_classification>`_ of the given predictions wrt ground truth.
+
+
     :param gt: ground truth
     :param pred: predictions
     :return: precision score
@@ -87,6 +96,8 @@ def precision(gt, pred):
 
 def f1score(gt, pred):
     """
+    Calculates `F1-score <https://en.wikipedia.org/wiki/F-score>`_ of the given predictions wrt ground truth.
+
     :param gt: ground truth
     :param pred: predictions
     :return: F1 score
@@ -98,6 +109,8 @@ def f1score(gt, pred):
 
 def accuracy(gt, pred):
     """
+    Calculates `accuracy <https://en.wikipedia.org/wiki/Precision_and_recall>`_ of the given predictions wrt ground truth.
+
     :param gt: ground truth
     :param pred: predictions
     :return: accuracy score
@@ -110,8 +123,7 @@ def accuracy(gt, pred):
 
 def count_topk_metric(res, top_k, metric, name='metric'):
     """
-    :param res: pd.DataFrame with prediction done by the model. Column 'rev' represents ground truth. Column 'top-k'
-    represents best k suggestions
+    :param res: pd.DataFrame with prediction done by the model. Column 'rev' represents ground truth. Column 'top-k' represents best k suggestions
     :param top_k: list with amount of the best suggestions
     :param metric: metric function
     :param name: name of the metric
@@ -147,7 +159,7 @@ def count_metrics(res, metrics=None, top_k=None):
     result = {}
     for metric in metrics:
         if metric == 'mrr':
-            result.update(count_mrr(res))
+            result.update(count_mrr(res['rev'], res['top-10']))
         else:
             result.update(count_topk_metric(res, top_k, metric_func[metric], metric))
     return result
diff --git a/batcore/modelbase/__init__.py b/batcore/modelbase/__init__.py
new file mode 100644
index 0000000..5810482
--- /dev/null
+++ b/batcore/modelbase/__init__.py
@@ -0,0 +1,7 @@
+from .recommender import RecommenderBase
+from .recommender import BanRecommenderBase
+
+__all__ = [
+    "RecommenderBase",
+    "BanRecommenderBase",
+]
diff --git a/RecommenderBase/recommender.py b/batcore/modelbase/recommender.py
similarity index 82%
rename from RecommenderBase/recommender.py
rename to batcore/modelbase/recommender.py
index ed44006..800407d 100644
--- a/RecommenderBase/recommender.py
+++ b/batcore/modelbase/recommender.py
@@ -11,8 +11,9 @@ def __init__(self):
     def predict(self, pull, n=10):
         """
         predicts best n reviewers for the pull
+
         :param pull: pull for which reviewers need to be predicted
-        :param n: number of reviewers to predict
+        :param int n: number of reviewers to predict
         :return: list of recommendations
         """
         pass
@@ -29,13 +30,12 @@ def fit(self, data):
 class BanRecommenderBase(RecommenderBase, ABC):
     """
     Base class for the recommender models with built-in filtering of the candidates.
+
+    :param no_owner: flag to add or remove owners of the pull request from the recommendations
+    :param no_inactive: flag to add or remove inactive reviewers from recommendations
+    :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
     """
     def __init__(self, no_owner=True, no_inactive=True, inactive_time=60):
-        """
-        :param no_owner: flag to add or remove owners of the pull request from the recommendations
-        :param no_inactive: flag to add or remove inactive reviewers from recommendations
-        :param inactive_time: number of consecutive days without any actions needed to be considered an inactive
-        """
         super().__init__()
         self.no_owner = no_owner
         self.no_inactive = no_inactive
@@ -54,6 +54,7 @@ def remove_user(scores, user_id):
     def remove_inactive(self, scores, cur_date):
         """
         removes recently inactive users from the resulstin scores
+
         :param scores: dict with scores for each reviewer candidate
         :param cur_date: date of the pull request for which scores were calculated
         """
@@ -67,6 +68,7 @@ def remove_inactive(self, scores, cur_date):
     def update_time(self, events):
         """
         for all the participants in each event updates time of most recent action
+
         :param events: batch of events
         """
         for event in events:
@@ -77,7 +79,7 @@ def update_time(self, events):
                         self.last_active[owner] = date
                 except KeyError:
                     pass
-                for reviewer in event['reviewer_login']:
+                for reviewer in event['reviewer']:
                     self.last_active[reviewer] = date
             else:
                 date = event['date']
@@ -86,8 +88,10 @@ def update_time(self, events):
 
     def filter(self, scores, pull):
         """
-        For the pr :param pull: with candidates score :param scores: removes all undesirable
-        :return:
+        For the given pull request filters candidates
+
+        :param pull: pull request for which recommendations are calculated
+        :param scores: dict scores of potential candidates for the code review
         """
         if self.no_owner:
             for owner in pull['owner']:
diff --git a/data/StandardDataset.py b/data/StandardDataset.py
deleted file mode 100644
index 9a887ac..0000000
--- a/data/StandardDataset.py
+++ /dev/null
@@ -1,215 +0,0 @@
-from copy import deepcopy
-
-import numpy as np
-import pandas as pd
-
-from data.DatasetBase import DatasetBase
-from data.utils import ItemMap, is_bot
-
-
-class StandardDataset(DatasetBase):
-    """
-    dataset for most of the implemented models.
-    """
-
-    def __init__(self,
-                 dataset,
-                 max_file=100,
-                 commits=False,
-                 comments=False,
-                 user_items=False,
-                 file_items=False,
-                 pull_items=False,
-                 remove_empty=False,
-                 owner_policy='author_owner_fallback',
-                 remove='none'
-                 ):
-        """
-
-        :param dataset: GerritLoader-like object
-        :param max_file: maximum number of files that a review can have
-        :param commits: if False commits are omitted from the data
-        :param comments: if False comments are omitted from the data
-        :param user_items: if True user2id map is created
-        :param file_items: if True file2id map is created
-        :param pull_items: if true pull2id map is created
-        :param owner_policy: how pull owners are calculated.
-            * None - owners are unchanged
-            * author - commit authors of the pull are treated as owners
-            * author_no_na - commit authors of the pull are treated as owners. pulls without an author are removed
-            * author_owner_fallback - if pull has author, owner field set to the author. Otherwise, nothing is done
-        :param remove: list of columns to remove from the reviewers. Can be a subset of ['owner', 'author']
-        """
-
-        if remove == 'none':
-            remove = ['owner']
-
-        self.bad_pulls = None
-        self.max_file = max_file
-        self.commits = commits
-        self.comments = comments
-
-        self.user_items = user_items
-        if self.user_items:
-            self.users = None
-
-        self.file_items = file_items
-        if self.file_items:
-            self.files = None
-
-        self.pull_items = pull_items
-        if self.pull_items:
-            self.pulls = None
-
-        self.owner_policy = owner_policy
-        self.remove = remove
-        self.remove_empty = remove_empty
-
-        super().__init__(dataset)
-
-    def preprocess(self, dataset):
-        """
-        :param dataset: GerritLoader-like dataset
-        :return: preprocess all necessary events and returns them as data stream
-        """
-
-        pulls = self.get_pulls(dataset)
-
-        events = {'pulls': pulls}
-        if self.commits:
-            commits = self.get_commits(dataset)
-            events['commits'] = commits
-
-        if self.comments:
-            comments = self.get_comments(dataset)
-            events['comments'] = comments
-        data = []
-        for event_type in events:
-            data += events[event_type].to_dict('records')
-        data = sorted(data, key=lambda x: x['date'])
-
-        self.additional_preprocessing(events, data)
-        self.events = events
-        return data
-
-    def get_pulls(self, dataset):
-        """
-        :param dataset: GerritLoader-like dataset
-        :return: preprocessed pulls dataframe
-        """
-        # remove opened pulls. only Merged and Abandoned stay
-        pulls = dataset.pulls[dataset.pulls.status != 'OPEN']
-        # remember big pulls
-        # self.bad_pulls = self.bad_pulls.union(set(pulls[pulls.file_path.apply(len) > self.max_file]['key_change']))
-        self.bad_pulls = set(pulls[pulls.file_path.apply(len) > self.max_file]['key_change'])
-
-        if self.remove_empty:
-        # remember pull w/out reviewers
-            self.bad_pulls = self.bad_pulls.union(set(pulls[pulls.reviewer_login.apply(len) == 0]['key_change']))
-
-        # remove big pulls and pull w/out reviewers
-        pulls = pulls[pulls.file_path.apply(len) <= self.max_file]
-        if self.remove_empty:
-            pulls = pulls[pulls.reviewer_login.apply(len) > 0]
-
-        # owner estimation
-        if self.owner_policy == 'author':
-            pulls.owner = pulls.author
-        elif self.owner_policy == 'author_no_na':
-            pulls.owner = pulls.author
-            pulls = pulls.dropna()
-        elif self.owner_policy == 'author_owner_fallback':
-            pulls.owner = pulls.apply(lambda x: x.author if len(x.author) else x.owner, axis=1)
-        elif self.owner_policy == 'none':
-            pulls.owner = pulls.owner.apply(lambda x: [int(i) for i in x])
-        else:
-            raise ValueError(f'Wrong owner_policy {self.owner_policy}')
-
-        pulls.reviewer_login = pulls.reviewer_login.apply(lambda x: [int(i) for i in x])
-
-        if len(self.remove):
-            for col in self.remove:
-                pulls.reviewer_login = pulls.apply(lambda x: [rev for rev in x['reviewer_login'] if rev not in x[col]],
-                                                   axis=1)
-
-        # add label
-        pulls.loc[:, 'type'] = 'pull'
-
-        return pulls
-
-    def get_commits(self, dataset):
-        """
-        :param dataset: GerritLoader-like dataset
-        :return: preprocessed commits dataframe
-        """
-        commits = dataset.commits
-        # remove commits to the bad pulls
-        commits = commits[~commits['key_change'].isin(self.bad_pulls)]
-        commits.loc[:, 'type'] = 'commit'
-        return commits
-
-    def get_comments(self, dataset):
-        """
-        :param dataset: GerritLoader-like dataset
-        :return: preprocessed commits dataframe
-        """
-        comments = dataset.comments
-        # remove comments to the bad pulls
-        comments = comments[~comments['key_change'].isin(self.bad_pulls)]
-        # comments['type'] = 'comment'
-        comments.loc[:, 'type'] = 'comment'
-        return comments
-
-    def itemize_users(self, events):
-        """
-        creates user2id map from events
-        """
-        user_list = []
-        if 'pulls' in events:
-            pulls = events['pulls']
-            user_list += pulls['reviewer_login'].sum() + pulls['owner'].sum()
-        if 'comments' in events:
-            user_list += events['comments']['key_user'].to_list()
-        if 'commits' in events:
-            user_list += events['commits']['key_user'].to_list()
-        self.users = ItemMap(user_list)
-
-    def itemize_pulls(self, events):
-        """
-        creates pull2id map from events
-        """
-        self.pulls = ItemMap(events['pulls']['key_change'])
-
-    def itemize_files(self, events):
-        """
-        creates file2id map from events
-        """
-        self.files = ItemMap(events['pulls']['file_path'].sum())
-
-    def additional_preprocessing(self, events, data):
-        """
-        creates all item2id maps
-        """
-        if self.user_items:
-            self.itemize_users(events)
-        if self.pull_items:
-            self.itemize_pulls(events)
-        if self.file_items:
-            self.itemize_files(events)
-
-    def replace(self, data, rev):
-        data = deepcopy(data)
-        l = len(data['reviewer_list'])
-        data['reviewer_list'][np.random.randint(l)] = rev
-
-        return data
-
-    def get_items2ids(self):
-        ret = {}
-        if self.user_items:
-            ret['users'] = self.users
-        if self.pull_items:
-            ret['pulls'] = self.pulls
-        if self.file_items:
-            ret['files'] = self.files
-        return ret
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..6fc219d
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,21 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS    =
+SPHINXBUILD   = python -msphinx
+SPHINXPROJ    = Batcore
+SOURCEDIR     = source
+BUILDDIR      = _build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/alias.rst b/docs/alias.rst
new file mode 100644
index 0000000..c694f88
--- /dev/null
+++ b/docs/alias.rst
@@ -0,0 +1,38 @@
+.. _alias_toplevel:
+
+==============
+Alias Matching
+==============
+
+This is an adaptation of the alias merging algorithm from `Mining Email Social Networks <https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/bird2006mes.pdf>`_.
+The steps of this method is straightforward: the complete weighted graph is built with contributors as nodes.
+The weight of each edge represents the similarity between two users based on the string comparison techniques.
+After that, the graph is split into clusters based on the distance between nodes.
+
+In the Gerrit data, contributors can have 3 features: name, login, and e-mail.
+
+
+* **Name normalization.** We remove all punctuation, suffixes, and prefixes from the names. Additionally, titles and clarification terms ('admin', 'support') are removed. After that, the name is transformed into lowercase. Lastly, first and last names are calculated as the first and last part term after a split by space symbol.
+
+* **Name similarity.** We measure the similarity between two names with a Levenstein distance normalized to $[0, 1]$ interval. The distance is calculated between full names, first names, and second names separately. The resulting name similarity is the minimum distance between full names and averaged distances between first and last names.
+
+* **Name-email similarity.** The distance between the name and the email is a 0-1 measure. The distance is deemed to be 0 if and only if the e-mail consists of the full name or first and last name.
+
+* **E-mail similarity.** For email similarity, we calculate normalized Levenstein distance between two bases (part of the e-mail preceding to @).
+
+* **Login-email similarity.** Levenstein distance between the base of the e-mail and the login.
+
+* **User identifier distance.** The resulting distance between two users is the minimum of the metrics above.
+
+After the distance is calculated, we split users into clusters via `Agglomerative Clustering <https://scikit-learn.org/stable/modules/generated/sklearn.cluster.AgglomerativeClustering.html>`_ with a complete linkage.
+All developers in one cluster are treated as the same and mapped to the one resulting id.
+
+.. autofunction:: batcore.alias.get_clusters
+
+.. autofunction:: batcore.alias.get_sim_matrix
+
+.. autofunction:: batcore.alias.sim_users
+
+.. autofunction:: batcore.alias.get_norm_levdist
+
+.. autofunction:: batcore.alias.name_handle_dist
\ No newline at end of file
diff --git a/docs/basemodels.rst b/docs/basemodels.rst
new file mode 100644
index 0000000..5c3e7ee
--- /dev/null
+++ b/docs/basemodels.rst
@@ -0,0 +1,15 @@
+.. _basemodels_toplevel:
+
+=======================
+Base Classes for Models
+=======================
+
+.. autoclass:: batcore.modelbase.RecommenderBase
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
+.. autoclass:: batcore.modelbase.BanRecommenderBase
+   :members:
+   :no-undoc-members:
+   :exclude-members:
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..5d88b60
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,37 @@
+# Configuration file for the Sphinx documentation builder.
+import os
+import sys
+
+sys.path.insert(0, os.path.abspath('..'))
+sys.path.insert(0, os.path.abspath('.'))
+# -- Project information
+
+project = 'BaTCoRe'
+copyright = '2022, JetBrains'
+author = 'JetBrains'
+
+release = '0.1'
+version = '0.1.0'
+
+# -- General configuration
+
+extensions = [
+    'sphinx.ext.doctest',
+    'sphinx.ext.autodoc',
+]
+
+intersphinx_mapping = {
+    'python': ('https://docs.python.org/3/', None),
+    'sphinx': ('https://www.sphinx-doc.org/en/master/', None),
+}
+
+intersphinx_disabled_domains = ['std']
+
+templates_path = ['_templates']
+
+# -- Options for HTML output
+
+html_theme = 'sphinx_rtd_theme'
+
+# -- Options for EPUB output
+epub_show_urls = 'footnote'
diff --git a/docs/data.rst b/docs/data.rst
new file mode 100644
index 0000000..0fce772
--- /dev/null
+++ b/docs/data.rst
@@ -0,0 +1,16 @@
+.. _data_toplevel:
+
+============
+Data Classes
+============
+
+.. autoclass:: batcore.data.StandardDataset
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
+.. autoclass:: batcore.data.MRLoaderData
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
diff --git a/docs/datadescription.rst b/docs/datadescription.rst
new file mode 100644
index 0000000..3dfab41
--- /dev/null
+++ b/docs/datadescription.rst
@@ -0,0 +1,110 @@
+.. _datadesc_toplevel:
+
+================
+Data Description
+================
+
+Processing MRLoader output
+==========================
+
+To process the data loaded with `MRLoader <https://github.com/JetBrains-Research/MR-loader>`_ we provide ``MRLoaderData`` class.
+``MRLoaderData`` processes data from the raw output and filters it from `from_date` to `to_date`:
+
+
+.. code-block:: python
+
+    from batcore.data import MRLoaderData
+
+    data = MRLoaderData('path/to/the/directory/containing/output/of/MRLoader',
+                         from_date=datetime(), # all events before are removed
+                         to_date=datetime(), # all events after are removed
+                        )
+
+
+Resulting instance of ``MRLoaderData`` has three relevant fields: ``pulls``, ``comments``, and ``commits``.
+
+Dataframes
+----------
+``pulls`` is a pandas DataFrame with information about pull requests. It has following fields:
+
+* **key_change** - identifier of the pull request
+* **file** - list of files' paths changed in the pull request
+* **reviewer** - list of reviewers
+* **date** - time of the creation of the pull request (datetime.datetime instance)
+* **owner** - list of owners of the pull requests. We identify users by strings with the following format "{name}:{e-mail}:{login}".
+* **title** - title of the pull request
+* **status** - status of the pull request (one of three: "MERGED", "OPEN", and "ABANDONED")
+* **closed** - date when pull request was closed
+* **author** - set with authors of the pull request (i.e. authors of the commits)
+
+``commits`` is a pandas DataFrame with information about commits. It has following fields:
+
+* **key_commit** - identifier of the commit
+* **key_change** - id of the pull request with the commit
+* **key_file** - file path of the changed file (in case of several changed files, there will be several entries with same `key_commit`)
+* **key_user** - author of the commit
+* **date** - date of the commit
+
+``comments`` is a pandas DataFrame with information about commits. It has following fields:
+
+* **key_change** - id of the pull request with the commit
+* **key_file** - file path of the changed file (nan when the comment was made not to the file)
+* **key_user** - author of the commit
+* **date** - date of the commit
+
+Saving and Loading
+------------------
+
+You can save data from ``MRLoaderData`` with:
+
+.. code-block:: python
+
+    data.to_checkpoint('path/to/checkpoint')
+
+
+And load with:
+
+.. code-block:: python
+
+    data = MRLoaderData().from_checkpoint('path/to/checkpoint')
+
+Custom data
+===========
+
+``from_checkpoint`` method can also be used to load custom data.
+Provided data should be in a specific format.
+Path to checkpoint should lead to the folder with `pulls.csv` and optionally with `comments.csv` and `commits.csv`.
+Those files should have a header with feature names from above and first index column.
+Not all of the features are necessary for all of the models and some of them can be omitted and replaced with nan values.
+All of the relevant columns must contain data in the form described above with the following exceptions:
+
+* `dates` - date-string with the following format "yyyy-mm-dd hh:mm:ss"
+* `user_ids` - can be anything, but if you use our implementation of alias matching it should be a string with the format "{name}:{e-mail}:{login}"
+
+
+Required data for different models
+----------------------------------
+
+* **ACRec**
+    * pulls: date, reviewer, key_change, owner
+    * comments: key_change, key_user
+* **cHRev**
+    * pulls: date, reviewer, key_change, file, owner
+    * comments: date, key_user, key_file
+* **RevFinder**
+    * pulls: date, reviewer, key_change, file, owner
+* **RevRec**
+    * pulls: date, reviewer, key_change, owner, file
+    * comments: date, key_user, key_file, key_change
+* **Tie**
+    * pulls: date, reviewer, key_change, title, file, owner
+* **WRC**
+    * pulls: date, reviewer, key_change, file, owner
+* **xFinder**
+    * pulls: date, reviewer, key_change, owner, file
+    * commits: date, key_user, key_file
+* **CN**
+    * pulls: date, reviewer, key_change, owner
+    * comments: date, key_user, key_change
+
+
diff --git a/docs/dataloader.rst b/docs/dataloader.rst
new file mode 100644
index 0000000..8f97969
--- /dev/null
+++ b/docs/dataloader.rst
@@ -0,0 +1,20 @@
+.. _dataloader_toplevel:
+
+==================
+DataLoader Classes
+==================
+
+.. autoclass:: batcore.data.LoaderBase
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
+.. autoclass:: batcore.data.PullLoader
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
+.. autoclass:: batcore.data.PullLoaderAliasTest
+   :members:
+   :no-undoc-members:
+   :exclude-members:
diff --git a/docs/examples.rst b/docs/examples.rst
new file mode 100644
index 0000000..d577b43
--- /dev/null
+++ b/docs/examples.rst
@@ -0,0 +1,99 @@
+.. _examples_toplevel:
+
+========
+Examples
+========
+
+Running implemented models
+==========================
+
+.. code-block:: python
+
+    from batcore.data import PullLoader
+    from batcore.tester import RecTester
+    from batcore.data import MRLoaderData
+    from batcore.baselines import CN
+    from batcore.data import get_gerrit_dataset
+
+    # reloads saved data from the checkpoint
+    data = MRLoaderData().from_checkpoint('projects/openstack')
+
+    # gets dataset for the CN model. Pull request with more than 56 files are removed
+    dataset = get_gerrit_dataset(data, max_file=56, model_cls=CN)
+
+    # creates an iterator over dataset that iterates over pull request one-by-one
+    data_iterator = PullLoader(dataset, 10)
+
+    # creates a CN model. dataset.get_items2ids() provides model with necessary encodings
+    # (eg. users2id, files2id) for optimization of evaluation
+    model = CN(dataset.get_items2ids())
+
+    # create a tester object
+    tester = RecTester()
+
+    # run the tester and receive dict with all the metrics
+    res = tester.test_recommender(model, data_iterator)
+
+
+Loading dataset from MRLoader output
+====================================
+
+.. code-block:: python
+
+    from batcore.data import MRLoaderData
+
+    data = MRLoaderData('path', # path to the directory containing output of MRLoader
+                         bots='', # path to file with bots or 'auto'
+                         project_name='', # name of the project for in case of auto bot detection
+                         from_checkpoint=False, # when true reloads saves data
+                         from_date=datetime(), # all events before are removed
+                         to_date=datetime(), # all events after are removed
+                         factorize_users=True, # when true users are replaced withs numerical ids
+                         alias=True, # when true users with close names/emails/logins are treated as one
+                         remove_bots=True # when true bots are removed from the data
+                        )
+
+Creating Dataset from GerritLoader
+==================================
+
+.. code-block:: python
+
+    from batcore.data import StandardDataset
+
+    dataset = StandardDataset(data, # instance of MRLoaderData
+                              max_file=100, # number of maximum files in a pull request
+                              commits=False, # if true commits are included in the dataset
+                              comments=False, # if true comments are included in the dataset
+                              user_items=False, # if true makes a user2id map
+                              file_items=False, # if true makes a file2id map
+                              pull_items=False, # if true makes a pull2id map
+                              remove_empty=False, # if true pull requests w/out reviewers are removed
+                              owner_policy='', # strategy for identification of the author of the pull request
+                              remove=[] # list of columns/features that will be removed
+                             )
+
+Creating new model
+======================
+
+To create new mode one can simply implement abstract class RecommenderBase with `fit` and `predict` methods.
+
+* `fit` is a methods that trains the model on the given `data`
+* `predict` is methods that returns list of candidates to review give pull request `pull`
+
+For any model implementing those two methods testing can be done the same way as with implemented baselines. The only exception is changing dataset initialization from `get_gerrit_dataset` to manual initialization. A simple example of the recommender implementation can be found below:
+
+.. code-block:: python
+
+    from batcore.modelbase import RecommenderBase
+    import numpy as np
+
+    class SimpleRecommender(RecommenderBase):
+        def __init__(self):
+            super().__init__()
+            self.reviewers = []
+
+        def predict(self, pull, n=10):
+            return [np.random.choice(self.reviewers)]
+
+        def fit(self, data):
+            self.reviewers.extend(event['reviewer'])
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..a9fe0f9
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,19 @@
+Welcome to BaTCoRe's documentation!
+===================================
+
+
+Contents
+--------
+
+.. toctree::
+   overview
+   basemodels
+   models
+   tester
+   recmetrics
+   simulmetrics
+   data
+   dataloader
+   alias
+   examples
+   datadescription
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..5a859f0
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,36 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+	set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=.
+set BUILDDIR=_build
+set SPHINXPROJ=Batcore
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+	echo.
+	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+	echo.installed, then set the SPHINXBUILD environment variable to point
+	echo.to the full path of the 'sphinx-build' executable. Alternatively you
+	echo.may add the Sphinx directory to PATH.
+	echo.
+	echo.If you don't have Sphinx installed, grab it from
+	echo.http://sphinx-doc.org/
+	exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/docs/models.rst b/docs/models.rst
new file mode 100644
index 0000000..0ad3970
--- /dev/null
+++ b/docs/models.rst
@@ -0,0 +1,45 @@
+.. _models_toplevel:
+
+================
+Available Models
+================
+
+.. autoclass:: batcore.baselines.ACRec
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
+.. autoclass:: batcore.baselines.cHRev
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
+.. autoclass:: batcore.baselines.RevFinder
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
+.. autoclass:: batcore.baselines.RevRec
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
+.. autoclass:: batcore.baselines.Tie
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
+.. autoclass:: batcore.baselines.WRC
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
+.. autoclass:: batcore.baselines.xFinder
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
+.. autoclass:: batcore.baselines.CN
+   :members:
+   :no-undoc-members:
+   :exclude-members:
diff --git a/docs/overview.rst b/docs/overview.rst
new file mode 100644
index 0000000..85aa47a
--- /dev/null
+++ b/docs/overview.rst
@@ -0,0 +1,50 @@
+.. _intro_toplevel:
+
+========
+Overview
+========
+
+BaTCoRe is a Python framework for testing and building models for code reviewer recommendation.
+BaTCoRe provides interfaces for building new models, methods for testing models in different settings, and implementation of several popular reviewer recommendation methods.
+
+Installing BaTCoRe
+====================
+BaTCoRe can be installed via pip::
+
+    $ pip install batcore
+
+
+Resources
+===========
+
+* Github repository - `https://github.com/JetBrains-Research/batcore`_
+* `Issue tracker`_
+* `PyPi page`_
+* Paper tbd
+
+.. _https://github.com/JetBrains-Research/batcore: https://github.com/JetBrains-Research/batcore
+.. _Issue tracker: https://github.com/JetBrains-Research/batcore/issues
+.. _PyPi page: https://pypi.org/project/batcore/0.1.1/
+
+
+
+Requirements
+============
+
+* `Python`_ 3.9
+* `NumPy`_ (1.21.5 or later)
+* `Pandas`_ (1.3.4 or later)
+* `scikit-learn`_ (0.24.2 or later)
+* `SciPy`_ (1.6.2 or later)
+* `NLTK`_ (3.7 or later)
+* `Levenshtein`_ (0.20.8 or later)
+* `python-louvain`_ (0.16 or later)
+
+.. _Python: https://www.python.org
+.. _NumPy: https://www.python.org
+.. _Pandas: https://pandas.pydata.org/pandas-docs/stable/index.html
+.. _scikit-learn: https://scikit-learn.org/stable/index.html
+.. _SciPy: https://scipy.org
+.. _NLTK: https://www.nltk.org
+.. _Levenshtein: https://pypi.org/project/python-Levenshtein/
+.. _python-louvain: https://python-louvain.readthedocs.io/en/latest/
\ No newline at end of file
diff --git a/docs/recmetrics.rst b/docs/recmetrics.rst
new file mode 100644
index 0000000..a2cc46e
--- /dev/null
+++ b/docs/recmetrics.rst
@@ -0,0 +1,21 @@
+.. _reculmetrics_toplevel:
+
+=============================
+Recommendation System Metrics
+=============================
+
+.. autofunction:: batcore.metrics.count_mrr
+
+.. autofunction:: batcore.metrics.recall
+
+.. autofunction:: batcore.metrics.precision
+
+.. autofunction:: batcore.metrics.accuracy
+
+.. autofunction:: batcore.metrics.f1score
+
+.. autofunction:: batcore.metrics.bootstrap_estimation
+
+.. autofunction:: batcore.metrics.count_topk_metric
+
+.. autofunction:: batcore.metrics.count_metrics
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 0000000..aa85e2e
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,10 @@
+numpy>=1.21.5
+scipy~=1.6.2
+python-louvain~=0.16
+pandas~=1.3.4
+tqdm~=4.64.0
+scikit-learn~=0.24.2
+nltk~=3.7
+levenshtein==0.20.8
+ray==2.2.0
+aliasmatching=1.0.0
diff --git a/docs/simulmetrics.rst b/docs/simulmetrics.rst
new file mode 100644
index 0000000..ad19f0f
--- /dev/null
+++ b/docs/simulmetrics.rst
@@ -0,0 +1,22 @@
+.. _simulmetrics_toplevel:
+
+=============================
+Metrics for Simulated History
+=============================
+
+.. autoclass:: batcore.counter.CounterBase
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
+.. autofunction:: batcore.counter.CoreWorkloadCounter
+
+.. autoclass:: batcore.counter.ExpertiseCounter
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
+.. autoclass:: batcore.counter.FaRCounter
+   :members:
+   :no-undoc-members:
+   :exclude-members:
diff --git a/example.py b/example.py
index e79a8da..c23f9ad 100644
--- a/example.py
+++ b/example.py
@@ -1,7 +1,8 @@
-from baselines import *
-from data import *
-from data import StreamDataLoader
-from tester import RecTester
+from batcore.data import PullLoader
+from batcore.tester import RecTester
+from batcore.data import MRLoaderData
+from batcore.baselines import *
+from batcore.data import get_gerrit_dataset
 import pandas as pd
 
 pd.options.mode.chained_assignment = None
@@ -21,13 +22,13 @@
     #                     remove_bots=True)
 
     # reloads saved data from the checkpoint
-    data = GerritLoader('projects/openstack_alias', from_checkpoint=True)
+    data = MRLoaderData('projects/openstack_alias', from_checkpoint=True)
 
     # gets dataset for the CN model. Pull request with more than 56 files are removed
     dataset = get_gerrit_dataset(data, max_file=56, model_cls=RevRec)
 
     # creates an iterator over dataset that iterates over pull request one-by-one
-    data_iterator = StreamDataLoader(dataset, 10)
+    data_iterator = PullLoader(dataset, 10)
 
     # create a CN model. dataset.get_items2ids() provides model with necessary encodings (eg. users2id, files2id) for
     # optimization of evaluation
diff --git a/experiments.py b/experiments.py
index 6c61b98..53d05c2 100644
--- a/experiments.py
+++ b/experiments.py
@@ -1,11 +1,6 @@
 from copy import deepcopy
 
-from Counter.CoreWorkloadCounter import CoreWorkloadCounter
-from Counter.ExpertiseCounter import ExpertiseCounter
-from Counter.FaRCounter import FaRCounter
-from baselines import *
-from data import *
-from tester import RecTester, SimulTester, RecTesterAliasTest
+from batcore import SimulTester, RecTesterAliasTest
 from utils import save_results
 from params import *
 
@@ -54,7 +49,7 @@ def test_recommendation_metrics(models_cls, path=None, data_path=None, data_args
         save_results(f'{path}.json', res[0], model)
 
 
-def test_project_metrics(models_cls, path=None, data_path=None, data_args=None, filter_args=None):
+def test_project_metrics(models_cls, path=None, data_path=None, data_args=None, filter_args=None, dataset_name=''):
     if filter_args is None:
         filter_args = {}
     if data_args is None:
@@ -81,8 +76,9 @@ def test_project_metrics(models_cls, path=None, data_path=None, data_args=None,
         else:
             model = mdl_cls(**model_kwargs)
 
+        # tester = SimulTesterCheckpoint()
         tester = SimulTester()
-        res = tester.test_recommender(model, data_iterator)
+        res = tester.test_recommender(model, data_iterator, dataset_name=dataset_name)
         print(f'Finished for {mdl_cls.__name__}')
         save_results(path, res, model)
 
@@ -95,17 +91,17 @@ def test_project_metrics(models_cls, path=None, data_path=None, data_args=None,
         # CN,
         # Tie,
         # RevRec,
-        # RevFinder,
-        WRC,
+        RevFinder,
+        # WRC,
     ]
-    # test_project_metrics(models,
-    #                      path='results/openstack_old/alias_wrc_base.json',
-    #                      data_path='projects/libreoffice/alias',
-    #                      filter_args={'no_owner': True, 'no_inactive': True, 'inactive_time': 30},
-    #                      data_args={'remove_empty': False})
-
-    test_recommendation_metrics(models,
-                                path='results/android/rec_base',
-                                data_path='projects/android/alias',
-                                filter_args={'no_owner': True, 'no_inactive': True, 'inactive_time': 30},
-                                data_args={'remove_empty': False})
+    test_project_metrics(models,
+                         path='results/android/proj_alias_base.json',
+                         data_path='projects/android/alias',
+                         filter_args={'no_owner': True, 'no_inactive': True, 'inactive_time': 30},
+                         data_args={'remove_empty': False}, dataset_name='android')
+
+    # test_recommendation_metrics(models,
+    #                             path='results/android/rec_base',
+    #                             data_path='projects/android/alias',
+    #                             filter_args={'no_owner': True, 'no_inactive': True, 'inactive_time': 30},
+    #                             data_args={'remove_empty': False})
diff --git a/params.py b/params.py
index 8b62956..578408a 100644
--- a/params.py
+++ b/params.py
@@ -1,5 +1,4 @@
-from data import StandardDataset, RevFinderDataset, RevRecDataset, TieDataset
-from data import PullLoader, PullLoaderAliasTest
+from batcore.data import StandardDataset, RevFinderDataset, RevRecDataset, TieDataset, PullLoader
 
 model_setup = {
     'ACRec': {'item2id': False,
@@ -43,7 +42,7 @@
             'dataset': 'standard',
             'dataset_kwargs': {'user_items': True, 'file_items': True},
             'iterator': 'batch',
-            'iterator_kwargs': {'batch_size': 50}},
+            'iterator_kwargs': {'batch_size': 1}},
     'xFinder': {'item2id': False,
                 'model_kwargs': {},
                 'dataset': 'standard',
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..274d8e9
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,23 @@
+[build-system]
+requires = ["setuptools>=61.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "batcore"
+version = "0.1.1"
+authors = [
+  { name="Farid", email="kraalfar@gmail.com" },
+]
+description = "BaTCoRe"
+readme = "README.md"
+requires-python = ">=3.7"
+classifiers = [
+    "Programming Language :: Python :: 3",
+    "License :: OSI Approved :: Apache Software License",
+    "Operating System :: OS Independent",
+]
+
+[project.urls]
+"Homepage" = "https://github.com/JetBrains-Research/batcore"
+"Bug Tracker" = "https://github.com/JetBrains-Research/batcore/issues"
+"Docs" = "https://batcore.readthedocs.io/en/latest/index.html#"
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index fc4f8c9..e882b38 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,17 +1,10 @@
 numpy>=1.21.5
 pandas~=1.3.4
-pip==21.2.2
 scikit-learn~=0.24.2
 scipy~=1.6.2
 tqdm~=4.64.0
 nltk~=3.7
-levenshtein=0.20.8
-zlib~=1.2.11
-wheel~=0.37.1
-brotli~=1.0.9
-openssl~=1.1.1q
-cryptography~=37.0.1
-tornado~=6.1
-setuptools~=62.1.0
-networkx~=2.8.4
-python-louvain~=0.16
\ No newline at end of file
+levenshtein==0.20.8
+python-louvain~=0.16
+ray==2.2.0
+aliasmatching=1.0.0
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..a80f9f7
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,6 @@
+[metadata]
+name = batcore
+version = 0.1.1
+
+[options]
+packages = find:
\ No newline at end of file


Test patch:
diff --git a/tester/RecTester.py b/batcore/tester/RecTester.py
similarity index 51%
rename from tester/RecTester.py
rename to batcore/tester/RecTester.py
index dd23590..912f54f 100644
--- a/tester/RecTester.py
+++ b/batcore/tester/RecTester.py
@@ -1,8 +1,9 @@
 import pandas as pd
 from tqdm import tqdm
 
-from tester.TesterBase import TesterBase
-from Metrics.metrics import count_metrics
+from batcore.tester.TesterBase import TesterBase
+from batcore.metrics.metrics import count_metrics
+from batcore.bat_logging import tester_logging
 
 
 class RecTester(TesterBase):
@@ -10,9 +11,13 @@ class RecTester(TesterBase):
     tester for the standard recommendations metrics
     """
 
+    @tester_logging
     def test_recommender(self,
                          recommender,
                          data_iterator,
+                         verbose=False,
+                         log_file_path=None,
+                         log_stdout=False,
                          top_ns=None,
                          *args, **kwargs):
         """
@@ -27,18 +32,19 @@ def test_recommender(self,
 
         self.recs = []
         cnt = 0
+        self.info(f"starting evaluation")
         for (train_data, test_data) in tqdm(data_iterator):
             cnt += 1
             recommender.fit(train_data)
             if len(test_data):
                 cur_rec = recommender.predict(test_data, n=max(top_ns))
-                y_pred = [[*[cur_rec[:n] for n in top_ns], test_data['reviewer_login'], test_data['key_change']]]
+                y_pred = [[*[cur_rec[:n] for n in top_ns], test_data['reviewer'], test_data['key_change']]]
                 y_pred = pd.DataFrame(y_pred, columns=[*[f'top-{n}' for n in top_ns], 'rev', 'key'])
                 self.recs.append(y_pred)
-            # if cnt > 5:
-            #     break
-        # print(cnt)
+            self.info(f"finished pull request #{cnt}")
 
+        self.info(f"finished evaluation")
+        self.info(f"calculating metrics")
         recs = pd.concat(self.recs, axis=0)
 
         return count_metrics(recs, None, top_ns), recs
@@ -54,6 +60,9 @@ def test_recommender(self,
                          data_iterator,
                          flag='self_review',
                          top_ns=None,
+                         verbose=False,
+                         log_file_path=None,
+                         log_stdout=False,
                          *args, **kwargs):
         """
         :param recommender: recommender to be tested. Must implement RecommenderBase interface
@@ -62,22 +71,33 @@ def test_recommender(self,
         :param top_ns: array of k-s to calculate accuracy@k
         :return: dictionary with acc@k and mean reciprocal rank, recommendations over time
         """
-        if top_ns is None:
-            top_ns = [1, 3, 5, 10]
+        self.setup_logger(verbose, log_file_path, log_stdout)
+        try:
+            if top_ns is None:
+                top_ns = [1, 3, 5, 10]
 
-        self.recs = []
-        cnt = 0
-        for (train_data, test_data) in tqdm(data_iterator):
-            cnt += 1
-            recommender.fit(train_data)
-            if len(test_data):
-                cur_rec = recommender.predict(test_data, n=max(top_ns))
-                preds = [[*[cur_rec[:n] for n in top_ns], test_data['reviewer_login'], test_data[flag],
-                          test_data['key_change']]]
-                preds = pd.DataFrame(preds, columns=[*[f'top-{n}' for n in top_ns], 'rev', 'filter_flag', 'key'])
-                self.recs.append(preds)
-
-        recs = pd.concat(self.recs, axis=0)
-        recs_filtered = recs[~recs.filter_flag]
+            self.recs = []
+            cnt = 0
+            self.info(f"starting evaluation")
+            for (train_data, test_data) in tqdm(data_iterator):
+                cnt += 1
+                recommender.fit(train_data)
+                if len(test_data):
+                    cur_rec = recommender.predict(test_data, n=max(top_ns))
+                    preds = [[*[cur_rec[:n] for n in top_ns], test_data['reviewer'], test_data[flag],
+                              test_data['key_change']]]
+                    preds = pd.DataFrame(preds, columns=[*[f'top-{n}' for n in top_ns], 'rev', 'filter_flag', 'key'])
+                    self.recs.append(preds)
+                self.info(f"finished pull request #{cnt}")
+                # if cnt > 50:
+                #     # print(np.mean(recommender.log))
+                #     break
+            self.info(f"finished evaluation")
+            self.info(f"calculating metrics")
+            recs = pd.concat(self.recs, axis=0)
+            recs_filtered = recs[~recs.filter_flag]
 
-        return count_metrics(recs, None, top_ns), count_metrics(recs_filtered, None, top_ns), recs
+            return count_metrics(recs, None, top_ns), count_metrics(recs_filtered, None, top_ns), recs
+        except Exception as e:
+            self.exception('got an exception')
+            raise
diff --git a/batcore/tester/SimulTester.py b/batcore/tester/SimulTester.py
new file mode 100644
index 0000000..3ae6a4b
--- /dev/null
+++ b/batcore/tester/SimulTester.py
@@ -0,0 +1,277 @@
+import os
+import pickle
+from copy import deepcopy
+
+from tqdm import tqdm
+
+from batcore.counter import CoreWorkloadCounter
+from batcore.counter import FaRCounter, ExpertiseCounter
+from batcore.tester.TesterBase import TesterBase
+
+from batcore.bat_logging import tester_logging
+
+
+class SimulTester(TesterBase):
+    """
+    Class for testing non-recommendation metrics on a simulated history
+    """
+
+    def __init__(self):
+        self.simulated = []
+        self.real = []
+
+    @tester_logging
+    def test_recommender(self,
+                         recommender,
+                         data_iterator,
+                         metrics=None,
+                         dataset_name='',
+                         from_checkpoint=False,
+                         *args, **kwargs):
+        """
+        :param recommender: recommender to be tested. Must implement RecommenderBase interface
+        :param data_iterator: iterator over dataset on which recommender will be tested. Must implement
+                              IteratorBase interface
+        :param metrics: dict of metrics that implement CounterBase interface
+        :return: list of calculated metrics
+        """
+
+        self.info(f"starting evaluation")
+        if metrics is None:
+            metrics = {'Core Workload': CoreWorkloadCounter(),
+                       'FaR': FaRCounter(data_iterator),
+                       'Expertise': ExpertiseCounter(data_iterator)}
+        if not from_checkpoint:
+            self.simulate(recommender, data_iterator)
+            self.info(f"saving simulation to checkpoints/{dataset_name}/{type(recommender).__name__}")
+            self.save(path=f'checkpoints/{dataset_name}/{type(recommender).__name__}')
+        else:
+            self.info(f"loading simulation from checkpoints/{dataset_name}/{type(recommender).__name__}")
+            self.load(path=f'checkpoints/{dataset_name}/{type(recommender).__name__}')
+
+        self.info(f"finished evaluation")
+        self.info(f"calculating metrics")
+
+        result = {metric_name: self.count_metric_dif(metrics[metric_name]) for metric_name in metrics}
+
+        return result
+
+    def simulate(self, recommender, dataset):
+        """
+        :param recommender: recommender used to simulate reviewer history
+        :param dataset: dataset for which history will be simulated
+        :return: None. all results are gathered in real and simulated fields
+        """
+        cnt = 0
+        self.info(f"starting simulation")
+        for i, (train_data, test_data) in tqdm(enumerate(dataset)):
+            cnt += 1
+            if i == 0:
+                for event in train_data:
+                    if event['type'] == 'pull':
+                        self.simulated.append(event)
+                        self.real.append(event)
+
+            recommender.fit(train_data)
+
+            cur_rec = recommender.predict(test_data, n=5)
+            self.real.append(deepcopy(test_data))
+
+            if len(cur_rec):
+                simulated_pull = dataset.replace(cur_rec[0])
+                self.simulated.append(simulated_pull)
+            else:
+                self.simulated.append(test_data)
+
+            self.info(f"finished simulation for pull request #{cnt}")
+
+    def count_metric_dif(self, metric):
+        """
+        calculates metric on actual and simulated history and returns its relative difference
+        """
+        original_metric = metric(self.real)
+        simulated_metric = metric(self.simulated)
+        # print(original_metric, simulated_metric)
+        dif = (simulated_metric / original_metric - 1) * 100
+
+        return dif
+
+    def save(self, path='checkpoints'):
+        if not os.path.exists(path):
+            os.makedirs(path)
+
+        with open(f'{path}/simulated.pkl', 'wb') as f:
+            pickle.dump(self.simulated, f)
+
+        with open(f'{path}/real.pkl', 'wb') as f:
+            pickle.dump(self.real, f)
+
+    def load(self, path='checkpoints'):
+        with open(f'{path}/simulated.pkl', 'rb') as f:
+            self.simulated = pickle.load(f)
+
+        with open(f'{path}/real.pkl', 'rb') as f:
+            self.real = pickle.load(f)
+
+
+class SimulTesterSingleSkip(SimulTester):
+    """
+    Class for testing non-recommendation metrics on a simulated history
+    """
+
+    def simulate(self, recommender, dataset):
+        """
+        :param recommender: recommender used to simulate reviewer history
+        :param dataset: dataset for which history will be simulated
+        :return: None. all results are gathered in real and simulated fields
+        """
+        cnt = 0
+        self.info(f"starting simulation")
+        for i, (train_data, test_data) in tqdm(enumerate(dataset)):
+            cnt += 1
+            if i == 0:
+                for event in train_data:
+                    if event['type'] == 'pull':
+                        self.simulated.append(event)
+                        self.real.append(event)
+
+            recommender.fit(train_data)
+
+            self.real.append(deepcopy(test_data))
+
+            if len(test_data['reviewer']) > 1:
+                cur_rec = recommender.predict(test_data, n=5)
+                if len(cur_rec) > 0:
+                    simulated_pull = dataset.replace(cur_rec[0])
+                    self.simulated.append(simulated_pull)
+                else:
+                    self.simulated.append(test_data)
+            else:
+                self.simulated.append(test_data)
+            self.info(f"finished simulation for pull request #{cnt}")
+
+    def count_metric_dif(self, metric):
+        """
+        calculates metric on actual and simulated history and returns its relative difference
+        """
+        original_metric = metric(self.real)
+        simulated_metric = metric(self.simulated)
+        # print(original_metric, simulated_metric)
+        dif = (simulated_metric / original_metric - 1) * 100
+
+        return dif
+
+    def save(self, path='checkpoints'):
+        if not os.path.exists(path):
+            os.makedirs(path)
+
+        with open(f'{path}/simulated.pkl', 'wb') as f:
+            pickle.dump(self.simulated, f)
+
+        with open(f'{path}/real.pkl', 'wb') as f:
+            pickle.dump(self.real, f)
+
+    def load(self, path='checkpoints'):
+        with open(f'{path}/simulated.pkl', 'rb') as f:
+            self.simulated = pickle.load(f)
+
+        with open(f'{path}/real.pkl', 'rb') as f:
+            self.real = pickle.load(f)
+
+
+class SimulTesterCheckpoint(TesterBase):
+    """
+    Class for testing non-recommendation metrics on a simulated history
+    """
+
+    def __init__(self):
+        self.simulated = []
+        self.real = []
+
+    @tester_logging
+    def test_recommender(self,
+                         recommender,
+                         data_iterator,
+                         metrics=None,
+                         from_checkpoint=True,
+                         *args, **kwargs):
+        """
+        :param recommender: recommender to be tested. Must implement RecommenderBase interface
+        :param data_iterator: iterator over dataset on which recommender will be tested. Must implement
+                              IteratorBase interface
+        :param metrics: dict of metrics that implement CounterBase interface
+        :return: list of calculated metrics
+        """
+
+        if metrics is None:
+            metrics = {'Core Workload': CoreWorkloadCounter(),
+                       'FaR': FaRCounter(data_iterator),
+                       'Expertise': ExpertiseCounter(data_iterator)}
+        self.simulate(recommender, data_iterator, from_checkpoint)
+
+        result = {metric_name: self.count_metric_dif(metrics[metric_name]) for metric_name in metrics}
+
+        return result
+
+    def simulate(self, recommender, dataset, from_checkpoint):
+        """
+        :param recommender: recommender used to simulate reviewer history
+        :param dataset: dataset for which history will be simulated
+        :return: None. all results are gathered in real and simulated fields
+        """
+        cnt = 0
+        if from_checkpoint:
+            self.load(recommender)
+        old_cnt = len(self.real)
+        for i, (train_data, test_data) in tqdm(enumerate(dataset)):
+            cnt += 1
+            if cnt < old_cnt:
+                continue
+            if cnt % 500 == 0:
+                self.save(recommender)
+            if i == 0:
+                for event in train_data:
+                    if event['type'] == 'pull':
+                        self.simulated.append(event)
+                        self.real.append(event)
+
+            recommender.fit(train_data)
+
+            cur_rec = recommender.predict(test_data, n=5)
+            self.real.append(deepcopy(test_data))
+
+            if len(cur_rec):
+                simulated_pull = dataset.replace(cur_rec[0])
+                self.simulated.append(simulated_pull)
+            else:
+                self.simulated.append(test_data)
+
+            if cnt > 100:
+                break
+
+    def count_metric_dif(self, metric):
+        """
+        calculates metric on actual and simulated history and returns its relative difference
+        """
+        original_metric = metric(self.real)
+        simulated_metric = metric(self.simulated)
+        # print(original_metric, simulated_metric)
+        dif = (simulated_metric / original_metric - 1) * 100
+
+        return dif
+
+    def save(self, model, path='checkpoints/libre'):
+        model.save()
+        with open(f'{path}/simulated.pkl', 'wb') as f:
+            pickle.dump(self.simulated, f)
+
+        with open(f'{path}/real.pkl', 'wb') as f:
+            pickle.dump(self.real, f)
+
+    def load(self, model, path='checkpoints/libre'):
+        model.load()
+        with open(f'{path}/simulated.pkl', 'rb') as f:
+            self.simulated = pickle.load(f)
+
+        with open(f'{path}/real.pkl', 'rb') as f:
+            self.real = pickle.load(f)
diff --git a/batcore/tester/TesterBase.py b/batcore/tester/TesterBase.py
new file mode 100644
index 0000000..07fbf34
--- /dev/null
+++ b/batcore/tester/TesterBase.py
@@ -0,0 +1,23 @@
+from batcore.bat_logging import Logger
+from abc import ABC, abstractmethod
+
+
+class TesterBase(ABC, Logger):
+    """
+    Base interface for the tester class
+
+    :param recommender: recommender to be tested
+    :param data_iterator: iterator over data on which the recommender will be tested.
+    :param args, kwargs: any additional params
+    """
+
+    @abstractmethod
+    def test_recommender(self,
+                         recommender,
+                         data_iterator,
+                         verbose=False,
+                         log_file_path=None,
+                         log_stdout=False,
+                         log_mode='a',
+                         *args, **kwargs):
+        self.setup_logger(verbose, log_file_path, log_stdout, log_mode)
diff --git a/tester/__init__.py b/batcore/tester/__init__.py
similarity index 58%
rename from tester/__init__.py
rename to batcore/tester/__init__.py
index 5678537..e164cbc 100644
--- a/tester/__init__.py
+++ b/batcore/tester/__init__.py
@@ -1,6 +1,7 @@
-from .RecTester import RecTester, RecTesterAliasTest
-from .SimulTester import SimulTester
 from .TesterBase import TesterBase
+from .SimulTester import *
+from .RecTester import *
+
 
 __all__ = [
     "RecTester",
diff --git a/docs/tester.rst b/docs/tester.rst
new file mode 100644
index 0000000..34278a5
--- /dev/null
+++ b/docs/tester.rst
@@ -0,0 +1,25 @@
+.. _tester_toplevel:
+
+===============
+Testing Methods
+===============
+
+.. autoclass:: batcore.tester.TesterBase
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
+.. autoclass:: batcore.tester.RecTester
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
+.. autoclass:: batcore.tester.RecTesterAliasTest
+   :members:
+   :no-undoc-members:
+   :exclude-members:
+
+.. autoclass:: batcore.tester.SimulTester
+   :members:
+   :no-undoc-members:
+   :exclude-members:
\ No newline at end of file
diff --git a/tester/SimulTester.py b/tester/SimulTester.py
deleted file mode 100644
index 4f1fb96..0000000
--- a/tester/SimulTester.py
+++ /dev/null
@@ -1,81 +0,0 @@
-from copy import deepcopy
-
-from tqdm import tqdm
-
-from Counter.CoreWorkloadCounter import CoreWorkloadCounter
-from Counter.ExpertiseCounter import ExpertiseCounter
-from Counter.FaRCounter import FaRCounter
-from tester.TesterBase import TesterBase
-
-
-class SimulTester(TesterBase):
-    """
-    Class for testing non-recommendation metrics on a simulated history
-    """
-
-    def __init__(self):
-        self.simulated = []
-        self.real = []
-
-    def test_recommender(self,
-                         recommender,
-                         data_iterator,
-                         metrics=None,
-                         *args, **kwargs):
-        """
-        :param recommender: recommender to be tested. Must implement RecommenderBase interface
-        :param data_iterator: iterator over dataset on which recommender will be tested. Must implement
-                              IteratorBase interface
-        :param metrics: dict of metrics that implement CounterBase interface
-        :return: list of calculated metrics
-        """
-
-        if metrics is None:
-            metrics = {'Core Workload': CoreWorkloadCounter,
-                       'FaR': FaRCounter(data_iterator),
-                       'Expertise': ExpertiseCounter(data_iterator)}
-        self.simulate(recommender, data_iterator)
-
-        result = {metric_name: self.count_metric_dif(metrics[metric_name]) for metric_name in metrics}
-
-        return result
-
-    def simulate(self, recommender, dataset):
-        """
-        :param recommender: recommender used to simulate reviewer history
-        :param dataset: dataset for which history will be simulated
-        :return: None. all results are gathered in real and simulated fields
-        """
-        cnt = 0
-        for i, (train_data, test_data) in tqdm(enumerate(dataset)):
-            cnt += 1
-            if i == 0:
-                for event in train_data:
-                    if event['type'] == 'pull':
-                        self.simulated.append(event)
-                        self.real.append(event)
-
-            recommender.fit(train_data)
-
-            cur_rec = recommender.predict(test_data, n=5)
-            self.real.append(deepcopy(test_data))
-
-            if len(cur_rec):
-                simulated_pull = dataset.replace(cur_rec[0])
-                self.simulated.append(simulated_pull)
-            else:
-                self.simulated.append(test_data)
-
-            if cnt > 200:
-                break
-
-    def count_metric_dif(self, metric):
-        """
-        calculates metric on actual and simulated history and returns its relative difference
-        """
-        original_metric = metric(self.real)
-        simulated_metric = metric(self.simulated)
-        # print(original_metric, simulated_metric)
-        dif = (simulated_metric / original_metric - 1) * 100
-
-        return dif
diff --git a/tester/TesterBase.py b/tester/TesterBase.py
deleted file mode 100644
index d89307b..0000000
--- a/tester/TesterBase.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from abc import ABC, abstractmethod
-
-
-class TesterBase(ABC):
-    """
-    Base interface for the tester class
-    """
-    @abstractmethod
-    def test_recommender(self,
-                         recommender,
-                         data_iterator,
-                         *args, **kwargs):
-        """
-        :param recommender: recommender to be tested
-        :param data_iterator: iterator over data on which the recommender will be tested.
-        :param args, kwargs: any additional params
-        """
-        pass