In [2]:
# --- Imports! ---
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

# --- To scale the data using z-score ---
#Standardise features by removing the mean and scaling to unit variance.
from sklearn.preprocessing import StandardScaler #Not necessary for DT, likely needed for SVM. 
                                                 #Will need implementation after DT, if not used during.

# --- Label Encoder ---
#Encode target labels with value between 0 and n_classes-1.
from sklearn.preprocessing import LabelEncoder, label_binarize

#From scikit learn - train_test_split splits arrays or matrices into random train and test subsets.
#Model selection is used for things like cross validation and hyper-parameter tuning.
from sklearn.model_selection import train_test_split

#Exhaustive search over specified parameter values for an estimator.
from sklearn.model_selection import GridSearchCV

#Class-wise stratified K-Fold cross-validator. Provides train/test indices to split data in train/test sets. 
#Returns stratified folds.
from sklearn.model_selection import StratifiedKFold

# --- Algorithms to use! ---
#Import Decision Tree Classifier!
#Regressor exists too, but assignment is for predicting player engagement at High/Medium/Low,
#...Thus classifier is more apt.
from sklearn.tree import DecisionTreeClassifier
#Import SVC (Support Vector Classification)!
#Was LinearSVC, but SVC allows for choice of kernel during implementation.
#Running with Linear (high number of data samples) and RBF (though this one takes a sec at O(n^2)). Considering running poly or sigmoid too.
from sklearn.svm import SVC

# --- Metrics to evaluate the model(s) ---
#Looking mainly for accuracy, precision, recall, F1 score, and AUC-ROC, but other evaluation metrics could be useful
from sklearn import metrics
from sklearn.metrics import (accuracy_score, precision_score, confusion_matrix, classification_report,
    recall_score, roc_auc_score, roc_curve, auc, f1_score, precision_recall_fscore_support, PrecisionRecallDisplay) #precision_recall_curve
#Not running precision_recall_curve anymore because that works with binary data, not multiclass.

# --- To ignore warnings ---
import warnings
warnings.filterwarnings("ignore")

# --- Dedicated Random State/Seed Variable ---
rs = 626
np.random.seed(rs)
In [3]:
# --- Import dataset into ogb_data dataframe ---
ogb_data = pd.read_csv("online_gaming_behavior_dataset.csv")
In [4]:
# --- Dataframe exploration ---
ogb_data.head() #Prints the head
Out[4]:
PlayerID Age Gender Location GameGenre PlayTimeHours InGamePurchases GameDifficulty SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked EngagementLevel
0 9000 43 Male Other Strategy 16.271119 0 Medium 6 108 79 25 Medium
1 9001 29 Female USA Strategy 5.525961 0 Medium 5 144 11 10 Medium
2 9002 22 Female USA Sports 8.223755 0 Easy 16 142 35 41 High
3 9003 35 Male USA Action 5.265351 1 Easy 9 85 57 47 Medium
4 9004 33 Male Europe Action 15.531945 0 Medium 2 131 95 37 Medium
In [5]:
ogb_data
Out[5]:
PlayerID Age Gender Location GameGenre PlayTimeHours InGamePurchases GameDifficulty SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked EngagementLevel
0 9000 43 Male Other Strategy 16.271119 0 Medium 6 108 79 25 Medium
1 9001 29 Female USA Strategy 5.525961 0 Medium 5 144 11 10 Medium
2 9002 22 Female USA Sports 8.223755 0 Easy 16 142 35 41 High
3 9003 35 Male USA Action 5.265351 1 Easy 9 85 57 47 Medium
4 9004 33 Male Europe Action 15.531945 0 Medium 2 131 95 37 Medium
... ... ... ... ... ... ... ... ... ... ... ... ... ...
40029 49029 32 Male USA Strategy 20.619662 0 Easy 4 75 85 14 Medium
40030 49030 44 Female Other Simulation 13.539280 0 Hard 19 114 71 27 High
40031 49031 15 Female USA RPG 0.240057 1 Easy 10 176 29 1 High
40032 49032 34 Male USA Sports 14.017818 1 Medium 3 128 70 10 Medium
40033 49033 19 Male USA Sports 10.083804 0 Easy 13 84 72 39 Medium

40034 rows × 13 columns

In [6]:
# --- Prints info about the dataset/frame ---
#The information contains the number of columns, column labels, column data types, memory usage, range index, and the number of cells in each column (non-null values).
ogb_data.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 40034 entries, 0 to 40033
Data columns (total 13 columns):
 #   Column                     Non-Null Count  Dtype  
---  ------                     --------------  -----  
 0   PlayerID                   40034 non-null  int64  
 1   Age                        40034 non-null  int64  
 2   Gender                     40034 non-null  object 
 3   Location                   40034 non-null  object 
 4   GameGenre                  40034 non-null  object 
 5   PlayTimeHours              40034 non-null  float64
 6   InGamePurchases            40034 non-null  int64  
 7   GameDifficulty             40034 non-null  object 
 8   SessionsPerWeek            40034 non-null  int64  
 9   AvgSessionDurationMinutes  40034 non-null  int64  
 10  PlayerLevel                40034 non-null  int64  
 11  AchievementsUnlocked       40034 non-null  int64  
 12  EngagementLevel            40034 non-null  object 
dtypes: float64(1), int64(7), object(5)
memory usage: 4.0+ MB
In [7]:
# --- Return number of unique values for each column ---
ogb_data.nunique()
Out[7]:
PlayerID                     40034
Age                             35
Gender                           2
Location                         4
GameGenre                        5
PlayTimeHours                40034
InGamePurchases                  2
GameDifficulty                   3
SessionsPerWeek                 20
AvgSessionDurationMinutes      170
PlayerLevel                     99
AchievementsUnlocked            50
EngagementLevel                  3
dtype: int64
In [8]:
# --- Dropping irrelevant/non-significant columns, based on identifiers and unique values. ---
#Currently only PlayerID is dropped.
ogb_data=ogb_data.drop(columns=['PlayerID'])  #axis 1 = columns
In [9]:
# --- Creating numerical columns ---
num_cols=['Age','PlayTimeHours','SessionsPerWeek','AvgSessionDurationMinutes','PlayerLevel','AchievementsUnlocked']

# --- Creating categorical variables ---
cat_cols=['Gender','Location','GameGenre','InGamePurchases','GameDifficulty','EngagementLevel']
In [10]:
# --- Checking summary statistics ---
ogb_data[num_cols].describe().T
Out[10]:
count mean std min 25% 50% 75% max
Age 40034.0 31.992531 10.043227 15.000000 23.000000 32.000000 41.000000 49.000000
PlayTimeHours 40034.0 12.024365 6.914638 0.000115 6.067501 12.008002 17.963831 23.999592
SessionsPerWeek 40034.0 9.471774 5.763667 0.000000 4.000000 9.000000 14.000000 19.000000
AvgSessionDurationMinutes 40034.0 94.792252 49.011375 10.000000 52.000000 95.000000 137.000000 179.000000
PlayerLevel 40034.0 49.655568 28.588379 1.000000 25.000000 49.000000 74.000000 99.000000
AchievementsUnlocked 40034.0 24.526477 14.430726 0.000000 12.000000 25.000000 37.000000 49.000000
In [11]:
# --- Creating histograms ---
ogb_data[num_cols].hist(figsize=(14,14), edgecolor='black', alpha=0.7, rwidth=0.9)
plt.show()
No description has been provided for this image
In [12]:
# --- Printing the % sub-categories of each category ---
for i in cat_cols:
    print(ogb_data[i].value_counts (normalize=True))
    print('*'*40)
Gender
Male      0.598466
Female    0.401534
Name: proportion, dtype: float64
****************************************
Location
USA       0.399660
Europe    0.299845
Asia      0.202203
Other     0.098291
Name: proportion, dtype: float64
****************************************
GameGenre
Sports        0.201029
Action        0.200804
Strategy      0.200130
Simulation    0.199406
RPG           0.198631
Name: proportion, dtype: float64
****************************************
InGamePurchases
0    0.799146
1    0.200854
Name: proportion, dtype: float64
****************************************
GameDifficulty
Easy      0.49995
Medium    0.30002
Hard      0.20003
Name: proportion, dtype: float64
****************************************
EngagementLevel
Medium    0.483939
High      0.258181
Low       0.257881
Name: proportion, dtype: float64
****************************************
In [13]:
#Might need tinkering,,
for i in cat_cols:
    if i!='EngagementLevel':
         (pd.crosstab(ogb_data[i],ogb_data['EngagementLevel'])).plot(kind='bar',figsize=(8,4),stacked=True)
         plt.ylabel('Engagement Level %')
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
In [14]:
# --- Mean of numerical variables grouped by engagement level ---
ogb_data.groupby(['EngagementLevel'])[num_cols].mean()
Out[14]:
Age PlayTimeHours SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked
EngagementLevel
High 31.920085 12.069238 14.254547 131.921827 50.823723 25.095975
Low 31.896939 12.104915 4.530511 66.882119 46.101414 22.661565
Medium 32.082120 11.957503 9.553267 89.856405 50.926293 25.216424
In [15]:
# --- Plotting the correlation between numerical variables ---
#Might need tinkering,,
plt.figure(figsize=(14,8))
sns.heatmap(ogb_data[num_cols].corr(), annot=True, cmap='RdBu')
Out[15]:
<Axes: >
No description has been provided for this image
In [16]:
# --- Target column (done before prepping data) ---
target_col = 'EngagementLevel' #Predicting Engagement Level
Y_raw = ogb_data[target_col].copy() #Saving a copy of original target column before dummy variable creation
if 'PlayerID' in ogb_data.columns:
    ogb_data = ogb_data.drop(columns=['PlayerID']) #Already dropped PlayerID earlier, this is juuuust in case
In [17]:
#NOTE: Before prepping data, may be worth making a copy of the data, and then implementing a label encoder.

# --- Creating list of dummy columns ---
to_get_dummies_for = ['Gender','Location','GameGenre','InGamePurchases','GameDifficulty'] #,'EngagementLevel'

# --- Creating dummy variables ---
ogb_data = pd.get_dummies(data = ogb_data, columns = to_get_dummies_for, drop_first = True)

# --- Mapping Engagement Level ---
# dict_GameDifficulty = {'Hard': 2, 'Medium': 1, 'Easy' : 0}
dict_EngagementLevel = {'High': 2, 'Medium': 1, 'Low' : 0}


#ogb_data['GameDifficulty'] = ogb_data.GameDifficulty.map(dict_GameDifficulty)
ogb_data['EngagementLevel'] = ogb_data.EngagementLevel.map(dict_EngagementLevel)

#print('Columns after dummy creation:', len(ogb_data.columns))
In [18]:
# --- Separating target variable from other variables, prepping X and Y ---
if target_col in ogb_data.columns:
    ogb_data = ogb_data.drop(columns=[target_col])

Y= Y_raw.copy()
X= ogb_data.drop(columns = []) #Was columns = ['EngagementLevel']

#Label encoder!
#Encodes target labels with values between 0 and n_classes-1.
le = LabelEncoder()
Y_enc = le.fit_transform(Y) #0/1/2 Low/Medium/High, might cause issues due to earlier use of dict_EngagementLevel?
class_names = le.classes_
#print('Encoded classes:', class_names)
In [19]:
# --- Splitting the data ---
#80/20 split, random state 626, stratify Y_enc
X_train, X_test, Y_train, Y_test = train_test_split(X, Y_enc, test_size = 0.2, stratify = Y_enc, random_state = rs)

#print('Train shape:', X_train.shape, 'Test shape:', X_test.shape)
In [20]:
# --- Scaling the data ---
sc = StandardScaler()

# Fit_transform on train data
X_train_scaled = sc.fit_transform(X_train)
#X_train_scaled=pd.DataFrame(X_train_scaled, columns=X.columns) #Needed?

# Transform on test data
X_test_scaled = sc.transform(X_test)
#X_test_scaled=pd.DataFrame(X_test_scaled, columns=X.columns) #Needed?
In [21]:
# --- Creating metric function as an evaluation helper  ---
#Taken from example, xticklabels and yticklabels were 'Not Attrite' and 'Attrite'
#Going by this, should do High/Med/Low and Not variants?
#WIP, but seems to work best with just High/Med/Low. Won't really plot Not variants. Nots were likely used for example due to binary nature!
def metrics_score(actual, predicted):
    print(classification_report(actual, predicted))

    cm = confusion_matrix(actual, predicted)
    plt.figure(figsize=(8,5))
    
    sns.heatmap(cm, annot=True,  fmt='.2f', 
                xticklabels=['High', 'Medium', 'Low'],
                yticklabels=['High', 'Medium', 'Low']) # ['High', 'Medium', 'Low', 'Not High', 'Not Medium', 'Not Low']
    plt.ylabel('Actual')
    plt.xlabel('Predicted')
    plt.show()
In [22]:
# --- Creating helper function for other evaluation metrics ---
def evaluate_model(name, model, X_test, Y_test, Y_probability=None):
    Y_predicted = model.predict(X_test)
    accuracy = accuracy_score(Y_test, Y_predicted)
    precision = precision_score(Y_test, Y_predicted, average='weighted', zero_division=0)
    recall = recall_score(Y_test, Y_predicted, average='weighted', zero_division=0)
    f1 = f1_score(Y_test, Y_predicted, average='weighted', zero_division=0)

    metrics = {
    'name' : name,
    'accuracy' : accuracy,
    'precision' : precision,
    'recall' : recall,
    'f1' : f1
    }

    try:
        if Y_probability is None:
            Y_probability = model.predict_probability(X_test)
        Y_test_bin = label.binarise(Y_test, classes=np.arange(len(class_names)))
        auc_roc_ovr = auc_roc_score(Y_test_bin, Y_probability, average='macro', multi_class='ovr')
        metrics['auc_roc_ovr'] = auc_roc_ovr
    except Exception as e:
        metrics['auc_roc_ovr'] = None

    print(f"\n--- {name} ---")
    print('Accuracy:', accuracy)
    print('Precision (weighted):', precision)
    print('Recall (weighted):', recall)
    print('F1 Score (weighted):', f1)
    print('AUC-ROC OVR (macro):', metrics['auc_roc_ovr'])
    print("\nClassification Report:\n", classification_report(Y_test, Y_predicted, target_names=class_names, zero_division=0))

    return metrics
In [23]:
# --- Implement Decision Tree Classifier ---
dt = DecisionTreeClassifier(random_state=rs)
dt.fit(X_train_scaled, Y_train) #Remember, DT doesn't necessarily need scaled data

dt_metrics = evaluate_model("Decision Tree", dt, X_test_scaled, Y_test)

# --- Hyperparameter Tuning for DT ---
param_grid_dt = {
    'max_depth' : [None, 5, 10, 20],
    'min_samples_split' : [2, 5, 10],
    'min_samples_leaf' : [1, 2, 4],
    'criterion' : ['gini', 'entropy']
}

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=rs)
grid_dt = GridSearchCV(DecisionTreeClassifier(random_state=rs), param_grid_dt, 
                       scoring='f1_weighted', cv=cv, n_jobs=1, verbose=1)

grid_dt.fit(X_train_scaled, Y_train)
print('Best DT parameters:', grid_dt.best_params_)
best_dt = grid_dt.best_estimator_
dt_tuned_metrics = evaluate_model("Tuned Decision Tree", best_dt, X_test_scaled, Y_test,
                                  Y_probability=best_dt.predict_proba(X_test_scaled))
--- Decision Tree ---
Accuracy: 0.8417634569751468
Precision (weighted): 0.8421461058731453
Recall (weighted): 0.8417634569751468
F1 Score (weighted): 0.8419217351642789
AUC-ROC OVR (macro): None

Classification Report:
               precision    recall  f1-score   support

        High       0.82      0.83      0.82      2067
         Low       0.81      0.82      0.81      2065
      Medium       0.87      0.86      0.87      3875

    accuracy                           0.84      8007
   macro avg       0.83      0.84      0.83      8007
weighted avg       0.84      0.84      0.84      8007

Fitting 5 folds for each of 72 candidates, totalling 360 fits
Best DT parameters: {'criterion': 'entropy', 'max_depth': 10, 'min_samples_leaf': 4, 'min_samples_split': 10}

--- Tuned Decision Tree ---
Accuracy: 0.9097040089921319
Precision (weighted): 0.9096598002035872
Recall (weighted): 0.9097040089921319
F1 Score (weighted): 0.9094922937562496
AUC-ROC OVR (macro): None

Classification Report:
               precision    recall  f1-score   support

        High       0.91      0.89      0.90      2067
         Low       0.90      0.87      0.89      2065
      Medium       0.91      0.94      0.92      3875

    accuracy                           0.91      8007
   macro avg       0.91      0.90      0.91      8007
weighted avg       0.91      0.91      0.91      8007

In [24]:
# --- Fitting SVM ---
svm = SVC(kernel='linear', random_state=rs) #LinearSVC!
# SVC accepts these kernels: 'linear’, ‘poly’, ‘rbf’, ‘sigmoid’, ‘precomputed’.
model = svm.fit(X= X_train_scaled, y = Y_train) # Would do Y = Y_train, but for some reason expects y instead?
                                                # Could be due to use of Y as a variable before...

#linear_metrics = evaluate_model('Linear Support Vector Classifier', svm, X_test_scaled, Y-train)
In [25]:
Y_pred_train_svm = model.predict(X_train_scaled)
metrics_score(Y_train, Y_pred_train_svm) #Confusion matrix for performance evaluation of training data
              precision    recall  f1-score   support

           0       0.88      0.85      0.86      8269
           1       0.79      0.71      0.75      8259
           2       0.81      0.87      0.84     15499

    accuracy                           0.82     32027
   macro avg       0.83      0.81      0.82     32027
weighted avg       0.82      0.82      0.82     32027

No description has been provided for this image
In [26]:
Y_pred_test_svm = model.predict(X_test_scaled)
metrics_score(Y_test, Y_pred_test_svm) #Confusion matrix for performance evaluation of test data
              precision    recall  f1-score   support

           0       0.90      0.85      0.87      2067
           1       0.80      0.72      0.76      2065
           2       0.81      0.88      0.84      3875

    accuracy                           0.83      8007
   macro avg       0.84      0.82      0.82      8007
weighted avg       0.83      0.83      0.83      8007

No description has been provided for this image
In [27]:
svm_rbf=SVC(kernel='rbf',probability=True, random_state=rs) # Radial Basis Function kernel!
svm_rbf.fit(X_train_scaled,Y_train)
Out[27]:
SVC(probability=True, random_state=626)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
SVC(probability=True, random_state=626)
In [28]:
# --- Predict on training data ---
#(Used to be paired with RBF fitting box above, split for the sake of slightly faster debugging)
#Note for future me: The RBF fits just fine, only have to evaluate it now. Don't torture yourself with graphing if you don't gotta.
#For now, all this will be commented out - I need sleep :P

#Y_scores_svm=svm_rbf.predict_proba(X_train_scaled)

#precisions_svm, recalls_svm, thresholds_svm = precision_recall_fscore_support(Y_train, Y_scores_svm[:,1], average = None)

# Plot values of precisions, recalls, and thresholds
#plt.figure(figsize=(10,7))
#plt.plot(thresholds_svm, precisions_svm[:-1], 'b--', label='precision') #[:-1] is list slicing notation, in this case, plotting all but last one, I *think*
#plt.plot(thresholds_svm, recalls_svm[:-1], 'g--', label = 'recall')
#plt.xlabel('Threshold')
#plt.legend(loc='upper left')
#plt.ylim([0,1])
#plt.show()
In [29]:
# --- RBF Evaluation ---
Y_pred = svm_rbf.predict(X_test)

precision, recall, fscore, support = precision_recall_fscore_support(Y_test, Y_pred, average=None)

# Print the results for each class
print("Metrics per class (average=None):")
print(f"Precision: {precision}")
print(f"Recall:    {recall}")
print(f"F-score:   {fscore}")
print(f"Support:   {support}")

# To get a single averaged score (e.g., 'weighted' average is useful for imbalanced datasets)
avg_precision, avg_recall, avg_fscore, _ = precision_recall_fscore_support(Y_test, Y_pred, average='weighted')

print("\nWeighted Average Metrics:")
print(f"Precision: {avg_precision:.4f}")
print(f"Recall:    {avg_recall:.4f}")
print(f"F-score:   {avg_fscore:.4f}")
Metrics per class (average=None):
Precision: [0.         0.25789934 0.        ]
Recall:    [0. 1. 0.]
F-score:   [0.         0.41004766 0.        ]
Support:   [2067 2065 3875]

Weighted Average Metrics:
Precision: 0.0665
Recall:    0.2579
F-score:   0.1058
In [30]:
print("\nClassification Report and Confusion Matrix (RBF):")
metrics_score(Y_test, Y_pred)
Classification Report and Confusion Matrix (RBF):
              precision    recall  f1-score   support

           0       0.00      0.00      0.00      2067
           1       0.26      1.00      0.41      2065
           2       0.00      0.00      0.00      3875

    accuracy                           0.26      8007
   macro avg       0.09      0.33      0.14      8007
weighted avg       0.07      0.26      0.11      8007

No description has been provided for this image
In [31]:
# Another attempt at graphing the results. Can't figure out how to binarise, so can't get PrecisionRecallDisplay to work.
#Neither .from_estimator, or .from_predictions.

#from sklearn.ensemble import RandomForestClassifier

#classifier = RandomForestClassifier(random_state=rs)
#classifier.fit(X_train, Y_train)

# Get the probability estimates for all classes
#Y_scores = classifier.predict_proba(X_test)
#classes = classifier.classes_

#fig, ax = plt.subplots(figsize=(8, 6))

#for i, class_label in enumerate(classes):
    # Pass only the scores for the current class
    #class_score = Y_scores[:, i]
    
    #PrecisionRecallDisplay.from_estimator(
        #classifier,
        #Y_test, 
        #class_score, 
        #pos_label=class_label, # Key parameter for non-binary targets, supposedly
        #name=f"Class {class_label}", 
        #ax=ax
    #)

#ax.set_title("Precision-Recall curve for each class (from estimator)")
#ax.legend()
#plt.show()
In [37]:
Y_pred_proba = svm_rbf.predict_proba(X_test_scaled)
rbf_roc_auc = roc_auc_score(Y_test, Y_pred_proba, multi_class='ovr') # average='macro']0.99
print(rbf_roc_auc)
0.9427441052164592
In [ ]: