Lecture 4 - Demo Notebook¶

Prelimilaries: Imports and stuff¶

In [1]:
%matplotlib inline
import numpy as np 
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.model_selection import train_test_split
from sklearn import tree
from sklearn.ensemble import RandomForestClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import roc_auc_score, balanced_accuracy_score
from sklearn.preprocessing import MinMaxScaler, normalize
from sklearn.linear_model import LogisticRegression

import statsmodels.api as sm
import statsmodels.formula.api as smf

from scipy.spatial.distance import pdist, cdist, squareform

# Data directory
DATA_DIR = "./../../data"

Section 1: Pre-processing¶

Read the data¶

In [2]:
# Parse the aggregated data frame
df_lq = pd.read_csv('{}/aggregated_extended_fc.csv'.format(DATA_DIR))
ts = pd.read_csv('{}/time_series_extended_fc.csv'.format(DATA_DIR))

Clean the data¶

We remove inactive students that did not click during weekdays and weekend for the first 5 weeks of the semester.¶

In [3]:
def remove_inactive_students(df, ts):
    df = df.fillna('NaN')
    
    #find all users weeks with 0 clicks on weekends and 0 clicks on weekdays during the first weeks of the semester
    df_first = ts[ts.week < 5]
    rows = np.where(np.logical_and(df_first.ch_total_clicks_weekend==0, df_first.ch_total_clicks_weekday == 0).to_numpy())[0]
    df_zero = df_first.iloc[rows,:]
    dropusers = np.unique(df_zero.user)

    ts = ts[ts.user.isin(dropusers)==False]
    df = df[df.user.isin(dropusers)==False]
    return df, ts

df_lq, ts = remove_inactive_students(df_lq, ts)
# print(df_lq.columns)
In [4]:
display(df_lq)
user ch_num_sessions ch_time_in_prob_sum ch_time_in_video_sum ch_ratio_clicks_weekend_day ch_total_clicks_weekend ch_total_clicks_weekday ch_time_sessions_mean ch_time_sessions_std bo_delay_lecture ... time_in_problem time_in_video lecture_delay content_anticipation mean_playback_speed relative_video_pause submissions submissions_correct clicks_weekend clicks_weekday
1 1 3.4 1698.4 9227.8 0.567500 4.0 179.4 3068.720238 1257.504407 -2883.367738 ... 1698.4 9227.8 -2883.367738 0.009091 1.122014 0.361389 9.0 5.9 4.0 179.4
2 2 5.3 2340.6 10801.3 26.562274 94.6 129.2 1750.289268 1024.134043 10027.216667 ... 2340.6 10801.3 10027.216667 0.237488 0.807090 0.272210 6.1 3.0 94.6 129.2
4 4 2.5 3787.3 7040.0 1.543889 58.4 64.9 3373.908333 1363.320365 -914.633333 ... 3787.3 7040.0 -914.633333 0.290421 0.846794 0.196403 0.3 0.1 58.4 64.9
5 5 2.5 2568.9 3718.9 0.009677 31.2 50.8 1753.647500 1190.793589 12406.195238 ... 2568.9 3718.9 12406.195238 0.036241 1.090196 0.308608 3.8 2.4 31.2 50.8
6 6 4.2 5475.2 9711.6 0.476263 105.1 46.7 20410.677619 1561.548415 -13723.616667 ... 5475.2 9711.6 -13723.616667 0.278603 0.932590 0.303720 1.9 1.2 105.1 46.7
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
272 281 2.2 2600.3 3071.8 0.213467 20.6 122.6 2429.026667 336.303590 -69623.272700 ... 2600.3 3071.8 -69623.272700 0.026691 1.717685 0.086820 10.9 6.7 20.6 122.6
276 285 1.8 2111.4 2130.4 0.236626 7.0 21.2 1378.954762 544.967013 23125.010000 ... 2111.4 2130.4 23125.010000 0.001667 0.839917 0.061321 5.1 2.6 7.0 21.2
277 286 1.9 2224.8 2408.1 0.326239 20.5 46.7 1744.663333 664.335107 4812.464286 ... 2224.8 2408.1 4812.464286 0.023393 1.137915 0.171036 6.5 4.1 20.5 46.7
281 291 4.0 6898.3 5657.2 0.080266 131.5 15.7 2323.840952 1623.425778 -5626.100000 ... 6898.3 5657.2 -5626.100000 0.157089 1.048739 0.192380 3.2 1.7 131.5 15.7
283 293 3.5 8127.5 113.4 0.632304 28.9 20.6 7963.627500 1001.514794 0.000000 ... 8127.5 113.4 0.000000 0.010000 0.246698 0.065734 5.0 3.4 28.9 20.6

234 rows × 49 columns

In [5]:
display(ts)
user week ch_num_sessions ch_time_in_prob_sum ch_time_in_video_sum ch_total_clicks_weekend ch_total_clicks_weekday bo_delay_lecture ma_competency_anti ma_content_anti mu_speed_playback_mean mu_frequency_action_relative_video_pause wa_num_subs wa_num_subs_correct wa_num_subs_perc_correct
1 1 0 7.0 326.0 15525.0 40.0 227.0 4492.833333 0.090909 0.090909 1.319288 0.345528 7.0 4.0 0.571429
2 2 0 4.0 1224.0 12209.0 1.0 258.0 -8998.000000 0.060606 0.060606 1.000000 0.230415 13.0 8.0 0.615385
4 4 0 4.0 1294.0 12037.0 140.0 46.0 -9146.333333 0.181818 0.181818 1.184140 0.267606 3.0 1.0 0.333333
5 5 0 2.0 1324.0 4440.0 119.0 0.0 0.000000 0.060606 0.060606 1.894958 0.407080 0.0 0.0 0.000000
6 6 0 3.0 1773.0 14462.0 102.0 144.0 -90548.166667 0.121212 0.121212 1.000000 0.413793 5.0 3.0 0.600000
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
2864 281 9 0.0 0.0 0.0 0.0 0.0 0.000000 0.000000 0.000000 0.000000 0.000000 0.0 0.0 0.000000
2868 285 9 0.0 0.0 0.0 0.0 0.0 0.000000 0.000000 0.000000 0.000000 0.000000 0.0 0.0 0.000000
2869 286 9 0.0 0.0 0.0 0.0 0.0 0.000000 0.000000 0.000000 0.000000 0.000000 0.0 0.0 0.000000
2873 291 9 0.0 0.0 0.0 0.0 0.0 0.000000 0.000000 0.000000 0.000000 0.000000 0.0 0.0 0.000000
2875 293 9 1.0 0.0 0.0 14.0 0.0 0.000000 0.000000 0.000000 0.000000 0.000000 0.0 0.0 0.000000

2340 rows × 15 columns

Prepare data for classification¶

Add a pass/fail label¶

In [6]:
# We first add a column to the dataframe containing the outcome variable
# compute pass/fail label
df_lq['passed'] = df_lq.grade >= 4
df_lq['passed'] = df_lq['passed'].astype(int)

Remove "bad" features and Split Data¶

In [7]:
# We then split the data in a train-test split (stratified by the outcome variable)
X = df_lq.drop(['user','grade', 'gender', 'category', 'year', 'passed'], axis=1)
y = df_lq['passed']
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=0, stratify=y) # split train and validation data set

Print pass/fail proportions¶

In [8]:
# The class proportions in train and validation sets are the same, thanks to the stratification on y
print(y_train.value_counts(normalize=True))
print(y_val.value_counts(normalize=True))
passed
1    0.604278
0    0.395722
Name: proportion, dtype: float64
passed
1    0.595745
0    0.404255
Name: proportion, dtype: float64

Define Evaluation Metrics (will see later in the slides)¶

In [9]:
def compute_scores(clf, X_train, y_train, X_test, y_test, roundnum = 3):
    clf.fit(X_train, y_train)
    y_pred = clf.predict(X_test)
    accuracy =  balanced_accuracy_score(y_test, y_pred)
    
    y_pred_proba = clf.predict_proba(X_test)[:,1]
    auc = roc_auc_score(y_test, y_pred_proba)
    
    return round(accuracy,roundnum), round(auc,roundnum)

Section 2: Decision Trees¶

Compute a decision tree of max depth 2 over all the features¶

In [10]:
clf = tree.DecisionTreeClassifier(max_depth=2, random_state=0, criterion='entropy')
accuracy, auc = compute_scores(clf, X_train, y_train, X_val, y_val)
print("Decision tree. Balanced Accuracy = {}, AUC = {}".format(accuracy, auc))
Decision tree. Balanced Accuracy = 0.577, AUC = 0.602

Visualize the decision tree¶

In [11]:
plt.figure(figsize=(20, 10))
tree.plot_tree(clf, feature_names=X_train.columns);
No description has been provided for this image

Does depth improves perfromance ?¶

In [12]:
# We can change the max depth
accuracy_list = []
auc_list = []
for depth in range(1,len(X_train.columns)):
    clf = tree.DecisionTreeClassifier(max_depth=depth, random_state=0, criterion='entropy')
    accuracy, auc = compute_scores(clf, X_train, y_train, X_val, y_val)
    accuracy_list.append(accuracy)
    auc_list.append(auc)
    # print("Decision tree. Depth = {}, Balanced Accuracy = {}, AUC = {}".format(depth, accuracy, auc))
x = list(range(1,len(X_train.columns)))
plt.plot(x, accuracy_list, 'r', label = 'accuraزy')
plt.plot(x, auc_list, 'b', label = 'auc')
plt.xlabel("Decision tree Depth")
plt.ylabel("EvaluationMetrics")
plt.ylim([0,1])
plt.legend()
plt.show()
No description has been provided for this image

Section 3: Random Forests¶

Next, we will use a random forest classifier instead of a decision tree.

In [13]:
rf = RandomForestClassifier(n_estimators=100, random_state=0, criterion='entropy') # create a Random Forest
accuracy, auc = compute_scores(rf, X_train, y_train, X_val, y_val)
print("Random Forest. Balanced Accuracy = {}, AUC = {}".format(accuracy, auc))
Random Forest. Balanced Accuracy = 0.586, AUC = 0.585

For a single tree, in fact, keeping a low depth is necessary to avoid overfitting and to reduce the variance. Random forests, instead, can have a higher depth, and consequently a lower bias, since the variance is reduced in the aggregation step.

In this case, decision trees seem to perform better than random forests. A reason for this behavior could be that the single tree is already very "stable", i.e. it will change a little in response to little changes in the data. If this was the case, the submodels in the ensemble forest would be all very similar to the single tree, if they were allowed to choose among all the features at every split. Since, though, only a random subset of features is considered at each split, some subtrees would choose bad splits and have overall bad performances.

Section 4: K-Nearest Neighbors¶

We only use the euclidean distance since all our features are numerical

In [14]:
feature = 'ch_time_in_prob_sum'

# Compute the pairwise distance matrix for all the elements of the training set
X_train_dist = squareform(pdist(X_train[feature].to_numpy().reshape(-1,1), metric='euclidean'))

# Compute the distance between all elements of the training set and of the validation set
X_val_dist = cdist(X_val[feature].to_numpy().reshape(-1,1), X_train[feature].to_numpy().reshape(-1,1), metric='euclidean')

X_train_dist
Out[14]:
array([[   0. ,  181.9,  932.5, ..., 1821.3, 1884.6, 2220.8],
       [ 181.9,    0. ,  750.6, ..., 1639.4, 2066.5, 2402.7],
       [ 932.5,  750.6,    0. , ...,  888.8, 2817.1, 3153.3],
       ...,
       [1821.3, 1639.4,  888.8, ...,    0. , 3705.9, 4042.1],
       [1884.6, 2066.5, 2817.1, ..., 3705.9,    0. ,  336.2],
       [2220.8, 2402.7, 3153.3, ..., 4042.1,  336.2,    0. ]])
In [15]:
print('Training set size:', X_train.shape)
print('Validation set size:', X_val.shape)
print('Training pairwise distances size:', X_train_dist.shape)
print('Validation distances size:', X_val_dist.shape)
Training set size: (187, 44)
Validation set size: (47, 44)
Training pairwise distances size: (187, 187)
Validation distances size: (47, 187)
In [16]:
knn = KNeighborsClassifier(n_neighbors=5, metric='precomputed')

accuracy, auc = compute_scores(knn, X_train_dist, y_train, X_val_dist, y_val)
print("k-nearest neighbors. Balanced Accuracy = {}, AUC = {}".format(accuracy, auc))
k-nearest neighbors. Balanced Accuracy = 0.533, AUC = 0.548

Section 5: Logistic regression¶

We normalize the data data using the MinMaxScaler such that all the features are on the same scale.

In [17]:
scaler = MinMaxScaler()
scaler.fit(X_train)

X_train_scaled = scaler.transform(X_train)
X_val_scaled  = scaler.transform(X_val)

clf = LogisticRegression(random_state=0)
accuracy, auc = compute_scores(clf, X_train_scaled, y_train, X_val_scaled, y_val)
print("Logistic Regression. Balanced Accuracy = {}, AUC = {}".format(accuracy, auc))
Logistic Regression. Balanced Accuracy = 0.559, AUC = 0.626

Section 6: Time Series - Your Turn¶

Build a classifier that can predict whether students pass the course after half of the course (5 weeks). You will need to use the data frame ts for this task. You can use kNN, RF, or decision tree. Train your model on the training data and predict on the test data.

We first drop all the weeks before week 5 and then perform the train/test split.

In [18]:
# Consider only data up to the 5th week
ts = ts[ts.week <= 5]
In [19]:
# Train-test split done on the users, so that all the rows corresponding to one user go into the same set.
users = ts.user.unique()
y = df_lq.passed
users_train, users_val, y_train, y_val = train_test_split(users, y, test_size=0.2, random_state=0, stratify=y)

X_train = ts[ts.user.isin(users_train)]
X_val = ts[ts.user.isin(users_val)]
In [20]:
# Sort indexes to make label arrays consistent with the data
y_train = y_train.sort_index()
y_val = y_val.sort_index()

Decision tree/Random forest¶

For decision tree and RF, we need to aggregate the features to be able to feed them directly in the mode. We use the mean as an aggregation function.

In [21]:
# Aggregate features

groups_train = X_train.drop('week', axis = 1).groupby('user', as_index = False)
groups_val = X_val.drop('week', axis = 1).groupby('user', as_index = False)

standard_train = groups_train.std()
averages_train = groups_train.mean()
standard_val = groups_val.std()
averages_val = groups_val.mean()

X_train_aggregate = pd.concat([standard_train, averages_train], axis=1)
X_val_aggregate = pd.concat([standard_val, averages_val], axis=1)
X_train_aggregate.head()
Out[21]:
user ch_num_sessions ch_time_in_prob_sum ch_time_in_video_sum ch_total_clicks_weekend ch_total_clicks_weekday bo_delay_lecture ma_competency_anti ma_content_anti mu_speed_playback_mean ... ch_total_clicks_weekend ch_total_clicks_weekday bo_delay_lecture ma_competency_anti ma_content_anti mu_speed_playback_mean mu_frequency_action_relative_video_pause wa_num_subs wa_num_subs_correct wa_num_subs_perc_correct
0 2 3.391165 2109.754962 6234.491952 82.771976 103.319730 100718.818602 0.099941 0.099941 0.000000 ... 120.000000 176.833333 24461.694444 0.140258 0.140258 1.000000 0.332633 10.000000 4.833333 0.441964
1 5 1.095445 2254.745433 2119.963773 57.595139 38.301001 42682.471658 0.074596 0.074596 0.452140 ... 52.000000 25.833333 -114.222222 0.060401 0.060401 1.395326 0.379019 2.500000 1.333333 0.166667
2 6 1.505545 2872.580959 4712.799423 70.164806 57.094366 38052.014201 0.093191 0.093191 0.000000 ... 126.500000 56.166667 -22872.694444 0.197672 0.197672 1.000000 0.380767 1.333333 0.833333 0.211111
3 7 1.760682 2224.075868 3908.428094 73.841497 78.863807 70834.903484 0.045196 0.045196 0.000000 ... 133.833333 80.500000 -77474.511111 0.149894 0.149894 1.000000 0.380495 3.666667 1.500000 0.338384
4 8 1.366260 3821.751924 2660.061704 110.199214 182.871540 33286.469085 0.110765 0.110765 0.027152 ... 234.333333 143.000000 3480.708333 0.210803 0.210803 1.011085 0.444130 2.666667 1.500000 0.291667

5 rows × 28 columns

In [22]:
clf = tree.DecisionTreeClassifier(max_depth=3, min_samples_leaf=5, min_samples_split=2, 
                                  random_state=0, criterion='entropy')

accuracy, auc = compute_scores(clf, X_train_aggregate, y_train, X_val_aggregate, y_val)
print("Decision tree. Balanced Accuracy = {}, AUC = {}".format(accuracy, auc))
Decision tree. Balanced Accuracy = 0.506, AUC = 0.536
In [23]:
rf = RandomForestClassifier(n_estimators=100, max_depth=5, random_state=0, criterion='entropy') # create a Random Forest

accuracy, auc = compute_scores(rf,X_train_aggregate, y_train, X_val_aggregate, y_val)
print("Random Forest. Balanced Accuracy = {}, AUC = {}".format(accuracy, auc))
Random Forest. Balanced Accuracy = 0.533, AUC = 0.609

K-Nearest Neighbors¶

For kNN, we don't need to aggregate the features. We directly compute the pairwise Euclidean distance between users, separately for each feature. We then normalize the pairwise distance matrices such that we can sum them up and feed them directly into the model.

In [24]:
feature = ['ch_time_in_prob_sum', 'ma_competency_anti','bo_delay_lecture']

X_train_vectors = X_train.groupby('user')[feature].agg(list)
X_val_vectors = X_val.groupby('user')[feature].agg(list)

X_train_vectors.head()
Out[24]:
ch_time_in_prob_sum ma_competency_anti bo_delay_lecture
user
2 [1224.0, 1310.0, 6185.0, 3195.0, 5545.0, 2515.0] [0.0606060606060606, 0.0535714285714285, 0.12,... [-8998.0, -22764.666666666668, 228700.0, -3375...
5 [1324.0, 266.0, 4700.0, 4014.0, 4881.0, 0.0] [0.0606060606060606, 0.0, 0.0, 0.1351351351351... [0.0, -67829.0, 67143.66666666667, 0.0, 0.0, 0.0]
6 [1773.0, 5152.0, 6475.0, 8562.0, 6753.0, 10076.0] [0.1212121212121212, 0.1071428571428571, 0.22,... [-90548.16666666669, -46688.0, 0.0, 0.0, 0.0, ...
7 [6410.0, 902.0, 5776.0, 4517.0, 1540.0, 3827.0] [0.1212121212121212, 0.1071428571428571, 0.2, ... [-96044.5, -100976.5, 0.0, -187836.66666666663...
8 [1527.0, 4861.0, 5211.0, 8722.0, 4512.0, 12462.0] [0.1515151515151515, 0.1071428571428571, 0.12,... [-26506.333333333332, 67210.33333333333, 0.0, ...
In [25]:
def normalize(distance_matrix):
    range_matrix = np.max(distance_matrix) - np.min(distance_matrix)
    normalized = (distance_matrix - np.min(distance_matrix)) / range_matrix
    return normalized

# Compute the pairwise distance matrix for all the elements of the training set, by computing
# the distances between the vectors, for each of the features selected, and summing up
X_train_dist = sum(map(lambda x: normalize(squareform(pdist(x[1].tolist(), metric='euclidean'))),
                       X_train_vectors.items()))

# Same thing but between all elements of the training set and of the validation set
X_val_dist = sum(map(lambda x: normalize(cdist(x[0][1].tolist(), x[1][1].tolist(), metric='euclidean')), 
                     zip(X_val_vectors.items(), X_train_vectors.items())))

X_train_dist
Out[25]:
array([[0.        , 0.63479635, 0.79863403, ..., 0.92701674, 0.79609908,
        0.86466875],
       [0.63479635, 0.        , 0.75841214, ..., 0.57145625, 0.54598916,
        0.74351221],
       [0.79863403, 0.75841214, 0.        , ..., 0.97851606, 0.88172496,
        0.43477215],
       ...,
       [0.92701674, 0.57145625, 0.97851606, ..., 0.        , 0.34186977,
        0.81487542],
       [0.79609908, 0.54598916, 0.88172496, ..., 0.34186977, 0.        ,
        0.72684563],
       [0.86466875, 0.74351221, 0.43477215, ..., 0.81487542, 0.72684563,
        0.        ]])
In [26]:
knn = KNeighborsClassifier(n_neighbors=5, metric='precomputed')

accuracy, auc = compute_scores(knn, X_train_dist, y_train, X_val_dist, y_val)
print("k-nearest neighbors. Balanced Accuracy = {}, AUC = {}".format(accuracy, auc))
k-nearest neighbors. Balanced Accuracy = 0.523, AUC = 0.544
In [ ]: