{"cells":[{"metadata":{},"cell_type":"markdown","source":"# Riiid classification using Naive Bayes"},{"metadata":{},"cell_type":"markdown","source":"The initial purpose of this notebook was to make a very simple baseline for my own usage to better understand the data and the submission process. As I got a fairly decent score (given the simplicity of the model) I decided to share the notebook in case it can be helpful to someone else. This model could be improved by adding more features."},{"metadata":{"_uuid":"d629ff2d2480ee46fbb7e2d37f6b5fab8052498a","_cell_guid":"79c7e3d0-c299-4dcb-8224-4455121ee9b0","trusted":true},"cell_type":"code","source":"import pandas as pd\nimport numpy as np\nimport riiideducation\nimport matplotlib.pyplot as plt\nfrom sklearn.metrics import roc_auc_score\nimport datetime\nimport sys","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"If you want to submit to the competition, the flag <code>TEST_PREDICTION</code> must be set to <code>False</code>, otherwise it will use a smaller train dataset"},{"metadata":{"trusted":true},"cell_type":"code","source":"TEST_PREDICTION = True","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"## Read the data\n<p>I read the data in feather format. Depending on the flag <code>TEST_PREDICTION</code> I will either read the full train dataset in feather format from <a href=\"https://www.kaggle.com/aralai/riiid-feather-dataset\">this</a> notebook or I will read two datasets for train/test from <a href=\"https://www.kaggle.com/aralai/riiid-creating-a-test-dataset\">this</a> notebook. You can check the notebook if you want more details on how the test set was created</p>"},{"metadata":{"trusted":true},"cell_type":"code","source":"%%time\nif not TEST_PREDICTION:\n    train = pd.read_feather('../input/riiid-feather-dataset/train.feather')\nelse:\n    train = pd.read_feather('../input/riiid-creating-a-test-dataset/train_df.feather')\n    test = pd.read_feather('../input/riiid-creating-a-test-dataset/test_df.feather')","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"## Simple Naive Bayes classifier\n<p>When we create the classifier we must provide a dictionary of pandas DataFrames. Each dataframe must contain an index which is the id of the feature and three columns with the total number of occurences of the feature in the train dataset, the total positive cases and the total negative cases. <p>\nWe define a threshold as well (by default 10). If any instance of the feature in the prediction dataset has less elements than the threshold in the training dataset, it will be ignored.<p>\n<code>predict</code> receives a dictionary (the keys must match with the dictionary provided on the creation step) of lists. All list must be the same size. Each value of the list is an instance that we want to predict.\n<p>We can incrementally update the model by using the function <code>update_model</code>."},{"metadata":{"trusted":true},"cell_type":"code","source":"class NaiveBayes:\n    def __init__(self, features, threshold=10):\n        assert type(features)==dict, 'parameter features is not a dictionary!'\n        for f in features.keys():\n            assert type(features[f])==pd.core.frame.DataFrame, 'Wrong datatype for {0}. Each entry of the dictionary must contain a pandas DataFrame'.format(f)\n            assert list(features[f].columns)==['total', 'positive', 'negative'], 'wrong columns in {0} DataFrame'.format(f)\n        self.THRESHOLD = threshold\n        self.features = features\n        self.prior_probability = {}\n        self.one_feature = list(features.keys())[0]\n        self.total_answers = features[self.one_feature]['total'].sum()\n        self.positive_answers = features[self.one_feature]['positive'].sum()\n        self.negative_answers = features[self.one_feature]['negative'].sum()\n        self.prior_probability['negative'] = self.negative_answers/self.total_answers\n        self.prior_probability['positive'] = self.positive_answers/self.total_answers\n        \n    def update_model(self, data):\n        assert data.keys()==self.features.keys(), \"Keys doesn't match!\"\n        for f in self.features.keys():\n            self.features[f].add(data[f], fill_value=0).astype('uint64')\n        self.total_answers += data[self.one_feature]['total'].sum()\n        self.positive_answers += data[self.one_feature]['positive'].sum()\n        self.negative_answers += data[self.one_feature]['negative'].sum()\n        self.prior_probability['negative'] = self.negative_answers/self.total_answers\n        self.prior_probability['positive'] = self.positive_answers/self.total_answers\n        \n        \n    def predict(self, data):\n        assert data.keys()==self.features.keys(), \"Keys doesn't match!\"\n        data_len = len(data[list(data.keys())[0]])\n        # pos and neg are the priors for positive and negative classes\n        pos = np.array([self.prior_probability['positive'] for _ in range(data_len)])\n        neg = np.array([self.prior_probability['negative'] for _ in range(data_len)])\n        # multiply the prior probability by the likelihood of each feature\n        for d in data.keys():\n            feature = pd.DataFrame({'id':data[d]})\n            counts=pd.merge(feature,self.features[d],left_on='id',right_index=True,how='left').fillna(0).astype('uint64').values\n            # counts.shape == (sample_len,4)\n            # counts[:,0]==id ; counts[:,1]==total ; counts[:,2]==positive ; counts[:,3]==negative\n            # e.g.: counts == array([[115,46,32,14],[124,10,7,3],[115,46,32,14]],dtype=uint64)\n            updatable = np.where(counts[:,1]>self.THRESHOLD)[0]\n            # e.g.: updatable == array([True,False,True])\n            pos[updatable] *= counts[updatable,2]/counts[updatable,1]\n            neg[updatable] *= counts[updatable,3]/counts[updatable,1]\n        return pos/(pos+neg)","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"## Prepare simple features<p>\nGiven a column name, this function creates a dataframe that we can use for the class NaiveBayes."},{"metadata":{"trusted":true},"cell_type":"code","source":"def prepare_features(dataset, col_name):\n    df = dataset.loc[dataset.content_type_id==0,[col_name,'answered_correctly']].groupby(col_name).agg(['count','sum'])\n    df.columns=['total', 'positive']\n    df = df.astype('uint64')\n    df['negative'] = df['total']-df['positive']\n    return df","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"### Questions<p>\nWe group by <code>content_id</code> to get the number of times that a question has been asked and how many times it has been answered correctly or incorrectly."},{"metadata":{"trusted":true},"cell_type":"code","source":"question_df = prepare_features(train,'content_id')\nquestion_df.head()","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"plt.hist((question_df['positive']/question_df['total']).values, bins =30)\nplt.show()","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"### Users<p>\nGrouping by <code>user_id</code>, we will get information about how good or bad is the student."},{"metadata":{"trusted":true},"cell_type":"code","source":"user_df = prepare_features(train,'user_id')\nuser_df.head()","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"plt.hist((user_df['positive']/user_df['total']).values, bins =30)\nplt.show()","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"This is just a simple baseline, but more features could be added in a similar way. Naive Bayes is making the \"naive\" assumption that all the features are independent. If we want it to work well, we should add feature that are not correlated."},{"metadata":{},"cell_type":"markdown","source":"## Predict on a test dataset\n<p>In this case we don't want to submit the solution but just evaluate it on a test dataset (as explained <a href=\"https://www.kaggle.com/aralai/riiid-creating-a-test-dataset\">here</a>)</p>"},{"metadata":{"trusted":true},"cell_type":"code","source":"class TestGenerator:\n    def __init__(self, df, grp_size=[50,10]):\n        self.df = df\n        self.answered_correctly = self.df.answered_correctly[self.df.content_type_id==0].values\n        self.predictions = np.zeros(len(self.answered_correctly))\n        self.grp_size = grp_size\n        self.start_idx=0\n        self.last_prediction_idx = 0\n        self.prediction_called = True\n        self.test_cols = [c for c in df.columns if c not in ['answered_correctly','user_answer']]\n        self.current_batch = {'prior_group_answers_correct':[], 'prior_group_responses':[]}\n\n    def iter_test(self):\n        while self.start_idx<len(self.df):\n            assert self.prediction_called, \"You must call `predict()` successfully before you can continue with `iter_test()`\"\n            self.prediction_called = False\n            self.end_idx = int(self.start_idx + max(1,np.random.normal(self.grp_size[0],self.grp_size[1])))\n            test_df = self.df.iloc[self.start_idx:self.end_idx]\n            answered_correctly_previous_batch = list(test_df['answered_correctly'])\n            user_answer_previous_batch = list(test_df['user_answer'])\n            test_df = test_df[self.test_cols]\n            test_df['prior_group_answers_correct'] = None\n            test_df['prior_group_responses'] = None\n            test_df.loc[test_df.index[0],'prior_group_answers_correct'] = str(self.current_batch['prior_group_answers_correct'])\n            test_df.loc[test_df.index[0],'prior_group_responses'] = str(self.current_batch['prior_group_responses'])\n            self.current_batch['prior_group_answers_correct'] = answered_correctly_previous_batch\n            self.current_batch['prior_group_responses'] = user_answer_previous_batch\n            yield test_df\n\n    def predict(self, prediction_df):\n        assert not self.prediction_called, \"You must get the next test sample from `iter_test()` first.\"\n        self.predictions[self.last_prediction_idx:self.last_prediction_idx+len(prediction_df)] = prediction_df.answered_correctly\n        self.last_prediction_idx += len(prediction_df)\n        self.start_idx = self.end_idx\n        self.prediction_called = True\n        if self.end_idx>=len(self.df):\n            print(\"Final AUC score: {0}\".format(roc_auc_score(self.answered_correctly,self.predictions)))","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"class Chrono:\n    def __init__(self):\n        self.chrono = {}\n        self.acc_time = {}\n    \n    def start(self, name):\n        self.chrono[name] = datetime.datetime.now().timestamp()\n    \n    def stop(self, name):\n        timestop = datetime.datetime.now().timestamp() - self.chrono[name]\n        if name in self.acc_time.keys():\n            self.acc_time[name] += timestop\n        else:\n            self.acc_time[name] = timestop","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"if TEST_PREDICTION:\n    tg = TestGenerator(test, grp_size=[1000,100])\n    nb = NaiveBayes({'question': question_df, 'user':user_df})\n    chrono = Chrono()","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"if TEST_PREDICTION:\n    chrono.start('total')\n    progress=0\n    np.random.seed(59)\n    for test_batch in tg.iter_test():\n        progress += len(test_batch)\n        sys.stdout.write(\"{0}\\r\".format(progress))\n\n        # HERE we can perform the incremental learning. That means train the model including data from the previous batch.\n        # We must be careful because retrain the model at every step can slow down a lot the submission.\n        # In this example I won't use incremental learning\n        chrono.start('update_model')\n        #nb.update_model({'question':question_incr_df, 'user':user_incr_df})\n        chrono.stop('update_model')\n\n        test_questions = test_batch.loc[test_batch.content_type_id==0,'content_id']\n        test_users = test_batch.loc[test_batch.content_type_id==0,'user_id']\n        test_rowids = test_batch.loc[test_batch.content_type_id==0,'row_id']\n        chrono.start('predict')\n        answered_correctly = nb.predict({'question':test_questions, 'user':test_users})\n        chrono.stop('predict')\n\n        tg.predict(pd.DataFrame({'row_id':test_rowids, 'answered_correctly': answered_correctly}))\n    chrono.stop('total')","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"The AUC with this test dataset is 0.724. The AUC of the submission is 0.742"},{"metadata":{"trusted":true},"cell_type":"code","source":"chrono.acc_time","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"The time statistics registered in <code>chrono</code> can be useful to detect bottlenecks in the submission."},{"metadata":{},"cell_type":"markdown","source":"## Submission<p>\nHere I create the real submission for the competition."},{"metadata":{"trusted":true},"cell_type":"code","source":"env = riiideducation.make_env()\niter_test = env.iter_test()","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"For each batch in <code>test_df</code>, we will predict the probability of answering correctly (<code>nb.predict</code>) and then we will send the resulting data back to the environment. This last part is done in <code>env.predict</code>. Notice that we must not create any <code>submission.csv</code> file, this is done automatically by <code>env.predict</code>."},{"metadata":{"trusted":true},"cell_type":"code","source":"for (test_df, sample_prediction_df) in iter_test:\n    test_questions = test_df['content_id']\n    test_users = test_df['user_id']\n    answered_correctly = nb.predict({'question':test_questions, 'user':test_users})\n    test_df['answered_correctly'] = answered_correctly\n    env.predict(test_df.loc[test_df['content_type_id']==0,['row_id','answered_correctly']])","execution_count":null,"outputs":[]}],"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"pygments_lexer":"ipython3","nbconvert_exporter":"python","version":"3.6.4","file_extension":".py","codemirror_mode":{"name":"ipython","version":3},"name":"python","mimetype":"text/x-python"}},"nbformat":4,"nbformat_minor":4}