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()
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 %')
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: >
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
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
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
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 [ ]: