In [1]:
import pandas as pd
import numpy as np

from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.cluster import KMeans
from sklearn.preprocessing import LabelEncoder, StandardScaler
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score, classification_report, silhouette_score, roc_auc_score, confusion_matrix, ConfusionMatrixDisplay
from sklearn.svm import SVC

import matplotlib.pyplot as plt
import seaborn as sns
In [2]:
data=pd.read_csv('online_gaming_behavior_dataset.csv')
In [3]:
print(data)
       PlayerID  Age  Gender Location   GameGenre  PlayTimeHours  \
0          9000   43    Male    Other    Strategy      16.271119   
1          9001   29  Female      USA    Strategy       5.525961   
2          9002   22  Female      USA      Sports       8.223755   
3          9003   35    Male      USA      Action       5.265351   
4          9004   33    Male   Europe      Action      15.531945   
...         ...  ...     ...      ...         ...            ...   
40029     49029   32    Male      USA    Strategy      20.619662   
40030     49030   44  Female    Other  Simulation      13.539280   
40031     49031   15  Female      USA         RPG       0.240057   
40032     49032   34    Male      USA      Sports      14.017818   
40033     49033   19    Male      USA      Sports      10.083804   

       InGamePurchases GameDifficulty  SessionsPerWeek  \
0                    0         Medium                6   
1                    0         Medium                5   
2                    0           Easy               16   
3                    1           Easy                9   
4                    0         Medium                2   
...                ...            ...              ...   
40029                0           Easy                4   
40030                0           Hard               19   
40031                1           Easy               10   
40032                1         Medium                3   
40033                0           Easy               13   

       AvgSessionDurationMinutes  PlayerLevel  AchievementsUnlocked  \
0                            108           79                    25   
1                            144           11                    10   
2                            142           35                    41   
3                             85           57                    47   
4                            131           95                    37   
...                          ...          ...                   ...   
40029                         75           85                    14   
40030                        114           71                    27   
40031                        176           29                     1   
40032                        128           70                    10   
40033                         84           72                    39   

      EngagementLevel  
0              Medium  
1              Medium  
2                High  
3              Medium  
4              Medium  
...               ...  
40029          Medium  
40030            High  
40031            High  
40032          Medium  
40033          Medium  

[40034 rows x 13 columns]
In [4]:
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 [5]:
data.describe()
Out[5]:
PlayerID Age PlayTimeHours InGamePurchases SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked
count 40034.000000 40034.000000 40034.000000 40034.000000 40034.000000 40034.000000 40034.000000 40034.000000
mean 29016.500000 31.992531 12.024365 0.200854 9.471774 94.792252 49.655568 24.526477
std 11556.964675 10.043227 6.914638 0.400644 5.763667 49.011375 28.588379 14.430726
min 9000.000000 15.000000 0.000115 0.000000 0.000000 10.000000 1.000000 0.000000
25% 19008.250000 23.000000 6.067501 0.000000 4.000000 52.000000 25.000000 12.000000
50% 29016.500000 32.000000 12.008002 0.000000 9.000000 95.000000 49.000000 25.000000
75% 39024.750000 41.000000 17.963831 0.000000 14.000000 137.000000 74.000000 37.000000
max 49033.000000 49.000000 23.999592 1.000000 19.000000 179.000000 99.000000 49.000000
In [6]:
data.head()
Out[6]:
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 [7]:
data.isnull().sum()
Out[7]:
PlayerID                     0
Age                          0
Gender                       0
Location                     0
GameGenre                    0
PlayTimeHours                0
InGamePurchases              0
GameDifficulty               0
SessionsPerWeek              0
AvgSessionDurationMinutes    0
PlayerLevel                  0
AchievementsUnlocked         0
EngagementLevel              0
dtype: int64

Data Overview Intepereation

  • The dataset contains 40,034 player records with 13 features - one of which is a PlayerID used as an identifier.
  • There are no missing values

Features

  • 7 numeric columns
  • 5 categorial column

Data Characteristics

  • Average age is 32
  • Average weekly playtime hours is 12
  • In game purchases are very rare
  • Average sessions per week is 9 - lasting 94.5 mins on average
  • Players of all levels play
In [8]:
plt.hist(data['Age'], bins=20)
plt.title("Age Distribution") 
plt.xlabel("Age") 
plt.ylabel("Frequency") 
plt.show()
No description has been provided for this image

The above histogram shows that age is distributed failry even across the 15-50 range. There aren't any spikes in the data for any particular age group. Most groups have a similar number of gamers. This spread is useful for modelling as there isnt a group that dominates the rest, therefore, there isn't an imbalance.

In [9]:
data['EngagementLevel'].value_counts().plot(kind='bar')
plt.title("Engagement Level Distribution")
plt.xlabel("Engagement Level")
plt.ylabel("Number of Players")
plt.show()
data['EngagementLevel'].value_counts()
No description has been provided for this image
Out[9]:
EngagementLevel
Medium    19374
High      10336
Low       10324
Name: count, dtype: int64

In terms of engagement, the data is farily balanced. Medium is the largest group with 19374 players, with high and low showing an almost equal amount of players

In [10]:
plt.figure(figsize=(6,4))
data['Gender'].value_counts().plot(kind='bar')
plt.title("Gender Distribution")
plt.xlabel("Gender")
plt.ylabel("Number of Players")
plt.show()

data['Gender'].value_counts()
No description has been provided for this image
Out[10]:
Gender
Male      23959
Female    16075
Name: count, dtype: int64

The gender split shows that there are more male players than there are female. We need to be weary of this when building our models, as the model will naturally show more examples of male players.

In [11]:
plt.figure(figsize=(8,4))
data['GameGenre'].value_counts().plot(kind='bar')
plt.title("Game Genre Distribution")
plt.xlabel("Game Genre")
plt.ylabel("Number of Players")
plt.show()

data['GameGenre'].value_counts()
No description has been provided for this image
Out[11]:
GameGenre
Sports        8048
Action        8039
Strategy      8012
Simulation    7983
RPG           7952
Name: count, dtype: int64

The game genre distribution is well balanced across all genres. Each genre has almost the same number of players, with differences of only around 100. The model will therefore not favour one genre over another.

In [12]:
plt.figure(figsize=(6,4))
data['GameDifficulty'].value_counts().plot(kind='bar')
plt.title("Game Difficulty Distribution")
plt.xlabel("Difficulty")
plt.ylabel("Number of Players")
plt.show()

data['GameDifficulty'].value_counts()
No description has been provided for this image
Out[12]:
GameDifficulty
Easy      20015
Medium    12011
Hard       8008
Name: count, dtype: int64

With the game difficulty, there is a clear bias towards the 'easy' level. Most players select easier gameplay, while fewer select hard.

In [13]:
plt.figure(figsize=(8,4))
data['Location'].value_counts().plot(kind='bar')
plt.title("Player Location Distribution")
plt.xlabel("Location")
plt.ylabel("Number of Players")
plt.show()

data['Location'].value_counts()
No description has been provided for this image
Out[13]:
Location
USA       16000
Europe    12004
Asia       8095
Other      3935
Name: count, dtype: int64

The dataset is dominated by players from USA and Europe, with Asia and Other representing a smaller proportion (Other being the smallest).

In [14]:
plt.hist(data['PlayTimeHours'], bins=20)
plt.title("Weekly Play Time Distribution")
plt.xlabel("Hours Played per Week")
plt.ylabel("Number of Players")
plt.show()

data['PlayTimeHours'].describe()
No description has been provided for this image
Out[14]:
count    40034.000000
mean        12.024365
std          6.914638
min          0.000115
25%          6.067501
50%         12.008002
75%         17.963831
max         23.999592
Name: PlayTimeHours, dtype: float64

The weekly playtime average sits at 12 hours. The median also shows 12 hours - which shows an even distribution of play hours. There doesn't seem to be any exteme outliers in the data.

In [15]:
plt.hist(data['InGamePurchases'], bins=5)
plt.title("In-Game Purchases Distribution")
plt.xlabel("Number of Purchases")
plt.ylabel("Number of Players")
plt.show()
data['InGamePurchases'].value_counts()
No description has been provided for this image
Out[15]:
InGamePurchases
0    31993
1     8041
Name: count, dtype: int64

The data heavily leans towards non-spending players, meaning that around 80% of players don't make in-app purchases.

In [16]:
plt.hist(data['SessionsPerWeek'], bins=20)
plt.title("Sessions Per Week Distribution")
plt.xlabel("Sessions Per Week")
plt.ylabel("Number of Players")
plt.show()

data['SessionsPerWeek'].describe()
No description has been provided for this image
Out[16]:
count    40034.000000
mean         9.471774
std          5.763667
min          0.000000
25%          4.000000
50%          9.000000
75%         14.000000
max         19.000000
Name: SessionsPerWeek, dtype: float64

The above data shows a balanced distribution of weekly sessions, with the average player logging around 9 sessions per week, with the median also being 9 sessions a week. The spready is fairly wide - ranging from 0 to 19 sessions. This suggests a range of players form casual gamers to more serious gamers.

In [17]:
plt.hist(data['AvgSessionDurationMinutes'], bins=20)
plt.title("Average Session Duration Distribution")
plt.xlabel("Minutes per Session")
plt.ylabel("Number of Players")
plt.show()

data['AvgSessionDurationMinutes'].describe()
No description has been provided for this image
Out[17]:
count    40034.000000
mean        94.792252
std         49.011375
min         10.000000
25%         52.000000
50%         95.000000
75%        137.000000
max        179.000000
Name: AvgSessionDurationMinutes, dtype: float64

The average sessions lasts around 95 minutes, with the median also 95 minutes. There is a wide spread of data, showing some casual gamers preferring shorter sessions (10 minutes) while others preferring longer (179 minutes).

In [18]:
plt.hist(data['PlayerLevel'], bins=20)
plt.title("Player Level Distribution")
plt.xlabel("Player Level")
plt.ylabel("Number of Players")
plt.show()

data['PlayerLevel'].describe()
No description has been provided for this image
Out[18]:
count    40034.000000
mean        49.655568
std         28.588379
min          1.000000
25%         25.000000
50%         49.000000
75%         74.000000
max         99.000000
Name: PlayerLevel, dtype: float64

The average player level is 50 and the median sits at 49. Players are evenly distributed across early, mid and late game levels.

In [19]:
plt.hist(data['AchievementsUnlocked'], bins=20)
plt.title("Achievements Unlocked Distribution")
plt.xlabel("Achievements")
plt.ylabel("Number of Players")
plt.show()

data['AchievementsUnlocked'].describe()
No description has been provided for this image
Out[19]:
count    40034.000000
mean        24.526477
std         14.430726
min          0.000000
25%         12.000000
50%         25.000000
75%         37.000000
max         49.000000
Name: AchievementsUnlocked, dtype: float64

Again, a balanced distribution is shown here, with the average achievements unlocked sitting at 24.5 and the median at 25. We could make the assumption that higher engagements is associated with more achievements unlocked.

Next we need to preprocess the data so that the model we choose can understand the data. First, we need to remove any unnecessary data. Any data that won't be used to predict engagement needs to be removed, so that the model doesn't learn meaningless patterns.

In [20]:
data = data.drop('PlayerID', axis=1)

data.head()

In [21]:
X = data.drop('EngagementLevel', axis=1)
y = data['EngagementLevel']

Above, we create a new dataset that excludes the engagement level. We don't want the model to be able to see the engagement level as we train it, so we've split the data into input features (X) and the target variable (Y i.e. engagement).

In [22]:
categorical_cols = ['Gender', 'Location', 'GameGenre', 'GameDifficulty']
X = pd.get_dummies(X, columns=categorical_cols, drop_first=True)
X.head()
Out[22]:
Age PlayTimeHours InGamePurchases SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked Gender_Male Location_Europe Location_Other Location_USA GameGenre_RPG GameGenre_Simulation GameGenre_Sports GameGenre_Strategy GameDifficulty_Hard GameDifficulty_Medium
0 43 16.271119 0 6 108 79 25 True False True False False False False True False True
1 29 5.525961 0 5 144 11 10 False False False True False False False True False True
2 22 8.223755 0 16 142 35 41 False False False True False False True False False False
3 35 5.265351 1 9 85 57 47 True False False True False False False False False False
4 33 15.531945 0 2 131 95 37 True True False False False False False False False True

The categorical features (Gender, Location, GameGenre, GameDifficulty) cannot be used directly by the model because they contain text values. To fix this, we apply one-hot encoding, which converts each category into true/false columns.

Using pd.get_dummies(..., drop_first=True) ensures the dataset is fully numeric while avoiding redundant dummy columns.

In [23]:
label_encoder = LabelEncoder()
y = label_encoder.fit_transform(y)
In [24]:
dict(zip(label_encoder.classes_, label_encoder.transform(label_encoder.classes_)))
Out[24]:
{'High': 0, 'Low': 1, 'Medium': 2}

Above we use label_encoder to convert the engagement category into numerical values so the model can learn to predict them.

In [25]:
X_train, X_test, y_train, y_test = train_test_split(
    X,
    y,
    test_size=0.3,
    random_state=42,
    stratify=y  
)

Above, we split our data into:

  • 70% traning data
  • 30% testing data

We use stratify=y to ensure that the proportions of High, Medium, and Low engagement levels remain consistent in both the training and test sets. random_state=42 ensures the split can be reproduced exactly, which is important for consistency in experiments.

In [26]:
scaler = StandardScaler()
In [27]:
X_train_scaled = scaler.fit_transform(X_train)
In [28]:
X_test_scaled = scaler.transform(X_test)

Support Vector Machines are sensitive to the scale of input features because they measure distances between data points to draw a decision boundary. Because our dataset contains features with different ranges, we need to standaridse the data. To fix this, we standadise the data using StandardScaler(). This transforms every feature so that the mean becomes 0 and the standard deviation becomes 1.

Decision Trees do not require scaling, so we only use the scaled data for the SVM model.

In [29]:
dt_classifier = DecisionTreeClassifier(random_state=42)
dt_classifier.fit(X_train, y_train)
y_train_pred_dt = dt_classifier.predict(X_train)
y_test_pred_dt = dt_classifier.predict(X_test)
In [30]:
print("Train accuracy (Decision Tree):", accuracy_score(y_train, y_train_pred_dt))
print("Test accuracy  (Decision Tree):", accuracy_score(y_test, y_test_pred_dt))
Train accuracy (Decision Tree): 1.0
Test accuracy  (Decision Tree): 0.8365664807260011
In [31]:
print("\nDecision Tree - Classification Report (Test Data)\n")
print(classification_report(y_test, y_test_pred_dt, target_names=label_encoder.classes_))
Decision Tree - Classification Report (Test Data)

              precision    recall  f1-score   support

        High       0.81      0.81      0.81      3101
         Low       0.80      0.81      0.81      3097
      Medium       0.87      0.86      0.87      5813

    accuracy                           0.84     12011
   macro avg       0.83      0.83      0.83     12011
weighted avg       0.84      0.84      0.84     12011

We first train a simple Decision Tree using the default settings on the unscaled features. We then evaluate the model on the test set using: Accuracy, Prevision, Recall, F1-Score.

Training accuracy at a 100% means the model predcits the training data perfectly. This however indicates overfitting, where the tree memorises the training data instead of learning general patterns. Test accuracy at 83.66% confirms the overfitting. This is common for decision trees with default settings. The classification report again shows overfitting. Despite this, it performs relatively well on the test set.

  • Medium engagement is predicted most accurately
  • High and low engagement are harder to seperate
In [32]:
svm_linear = SVC(kernel='linear')
svm_linear.fit(X_train_scaled, y_train)
y_train_predict_svm_linear = svm_linear.predict(X_train_scaled)
y_test_predict_svm_linear = svm_linear.predict(X_test_scaled)
In [33]:
print("Train accuracy (SVM Linear):", accuracy_score(y_train, y_train_predict_svm_linear))
print("Test accuracy  (SVM Linear):", accuracy_score(y_test, y_test_predict_svm_linear))
Train accuracy (SVM Linear): 0.8253934268279628
Test accuracy  (SVM Linear): 0.8226625593206227
In [34]:
print("\nSVM (Linear Kernel) - Classification Report (Test Data)\n")
print(classification_report(y_test, y_test_predict_svm_linear, target_names=label_encoder.classes_))
SVM (Linear Kernel) - Classification Report (Test Data)

              precision    recall  f1-score   support

        High       0.88      0.85      0.87      3101
         Low       0.79      0.70      0.74      3097
      Medium       0.81      0.87      0.84      5813

    accuracy                           0.82     12011
   macro avg       0.83      0.81      0.82     12011
weighted avg       0.82      0.82      0.82     12011

Above, we trained a Support Vector Machine using a linear kernel to predict player engagement levels.
This model tries to separate the classes (High, Medium, Low) using straight-line decision boundaries The train and test accuracies are almost identica - shows that, it generalises well to new, unseen players and the decision boundaries are stable and consistent.

ROC-AUC tells us how well the model separates the three engagement levels. To calculate it, we need the model to give probability scores, not just predictions. Setting probability=True lets the SVM do this, and we then use those probabilities to get the ROC-AUC score.

In [35]:
svm_linear_proba = SVC(kernel='linear', probability=True, random_state=42)
svm_linear_proba.fit(X_train_scaled, y_train)
y_test_proba_svm_linear = svm_linear_proba.predict_proba(X_test_scaled)
svm_linear_roc_auc = roc_auc_score(y_test, y_test_proba_svm_linear, multi_class='ovr')
print("ROC-AUC (SVM Linear):", svm_linear_roc_auc)
ROC-AUC (SVM Linear): 0.9149357056855244

Below we use the Decision Tree’s predicted probabilities to calculate its ROC-AUC score. This shows how well the baseline model separates the three engagement levels before any tuning.

In [36]:
# Calculate AUC-ROC for baseline Decision Tree
y_test_proba_dt_baseline = dt_classifier.predict_proba(X_test)
dt_baseline_roc_auc = roc_auc_score(y_test, y_test_proba_dt_baseline, multi_class='ovr')
print("ROC-AUC (Baseline Decision Tree):", dt_baseline_roc_auc)
ROC-AUC (Baseline Decision Tree): 0.8715121654936967

We train an SVM using the RBF kernel, which can capture more complex, curved patterns in the data. After fitting the model, we make predictions on both the training and test sets to see how well it perfors.

In [37]:
svm_rbf = SVC(kernel='rbf')
svm_rbf.fit(X_train_scaled, y_train)
y_train_predict_svm_rbf = svm_rbf.predict(X_train_scaled)
y_test_predict_svm_rbf  = svm_rbf.predict(X_test_scaled)
In [38]:
print("Train accuracy (SVM RBF):", accuracy_score(y_train, y_train_predict_svm_rbf))
print("Test accuracy  (SVM RBF):", accuracy_score(y_test, y_test_predict_svm_rbf))
Train accuracy (SVM RBF): 0.9067551653998501
Test accuracy  (SVM RBF): 0.8830238947631338

These accuracy scores show how well the RBF SVM learned the patterns in the data. The training accuracy is slightly higher than the test accuracy, which is norma. This tells us the model is learning well witholy overfitting and is able to generalise to new players.

In [39]:
print("\nSVM (RBF Kernel) - Classification Report (Test Data)\n")
print(classification_report(y_test, y_test_predict_svm_rbf, target_names=label_encoder.classes_))
SVM (RBF Kernel) - Classification Report (Test Data)

              precision    recall  f1-score   support

        High       0.91      0.85      0.88      3101
         Low       0.88      0.82      0.85      3097
      Medium       0.87      0.93      0.90      5813

    accuracy                           0.88     12011
   macro avg       0.89      0.87      0.88     12011
weighted avg       0.88      0.88      0.88     12011

The RBF kernel lets the SVM pick up more curved, non-linear patterns, which fits this kind of gameplay data well. It performs better than both the Decision Tree and the linear SVM, and the training and test scores are close together, so it doesn’t look like the model is overfitting.

Medium engagement has the best recall (0.93), meaning the model is good at spotting players in this group. High and Low engagement have similar precision, and both show an improvement compared to the linear SVM.

Below, the same steps are used here as with the previous ROC-AUC calculation. We enable probability output, get the class probabilities, and then measure how well the model separates the three engagement level. ses.

In [40]:
svm_rbf_proba = SVC(kernel='rbf', probability=True, random_state=42)
svm_rbf_proba.fit(X_train_scaled, y_train)
y_test_proba_svm_rbf_baseline = svm_rbf_proba.predict_proba(X_test_scaled)
svm_rbf_baseline_roc_auc = roc_auc_score(y_test, y_test_proba_svm_rbf_baseline, multi_class='ovr')
print("ROC-AUC (Baseline SVM RBF):", svm_rbf_baseline_roc_auc)
ROC-AUC (Baseline SVM RBF): 0.9406418249231491

The ROC-AUC score for the RBF SVM is about 0.94, which is very strong. This means the model is good at separating High, Medium, and Low engagement players based on their behaviour.

Here we create a Decision Tree model and define a range of settings (max depth, minimum samples per split/leaf, and the splitting criterion) to test. We then use GridSearchCV to try all combinations and find the setup that gives the best performance. We then score the models using weighted F1

In [41]:
dt_model = DecisionTreeClassifier(random_state=42)
In [42]:
param_grid_dt = {
    'max_depth': [None, 5, 10, 15, 20],
    'min_samples_split': [2, 5, 10],
    'min_samples_leaf': [1, 2, 4],
    'criterion': ['gini', 'entropy']
}
In [43]:
grid_dt = GridSearchCV(
    estimator=dt_model,
    param_grid=param_grid_dt,
    cv=3,                     
    scoring='f1_weighted',     
    n_jobs=-1,                
    verbose=1                 
)
In [44]:
grid_dt.fit(X_train, y_train)
Fitting 3 folds for each of 90 candidates, totalling 270 fits
Out[44]:
GridSearchCV(cv=3, estimator=DecisionTreeClassifier(random_state=42), n_jobs=-1,
             param_grid={'criterion': ['gini', 'entropy'],
                         'max_depth': [None, 5, 10, 15, 20],
                         'min_samples_leaf': [1, 2, 4],
                         'min_samples_split': [2, 5, 10]},
             scoring='f1_weighted', verbose=1)
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.
GridSearchCV(cv=3, estimator=DecisionTreeClassifier(random_state=42), n_jobs=-1,
             param_grid={'criterion': ['gini', 'entropy'],
                         'max_depth': [None, 5, 10, 15, 20],
                         'min_samples_leaf': [1, 2, 4],
                         'min_samples_split': [2, 5, 10]},
             scoring='f1_weighted', verbose=1)
DecisionTreeClassifier(random_state=42)
DecisionTreeClassifier(random_state=42)
In [45]:
print("Best parameters (Decision Tree):", grid_dt.best_params_)
print("Best cross-val F1 (weighted):", grid_dt.best_score_)
Best parameters (Decision Tree): {'criterion': 'entropy', 'max_depth': 10, 'min_samples_leaf': 4, 'min_samples_split': 10}
Best cross-val F1 (weighted): 0.8952118174157863
In [46]:
best_dt = grid_dt.best_estimator_

y_test_pred_best_dt = best_dt.predict(X_test)

print("Test accuracy (Tuned Decision Tree):", accuracy_score(y_test, y_test_pred_best_dt))
print("\nTuned Decision Tree - Classification Report (Test Data)\n")
print(classification_report(y_test, y_test_pred_best_dt, target_names=label_encoder.classes_))
Test accuracy (Tuned Decision Tree): 0.89975855465823

Tuned Decision Tree - Classification Report (Test Data)

              precision    recall  f1-score   support

        High       0.90      0.89      0.89      3101
         Low       0.89      0.85      0.87      3097
      Medium       0.90      0.93      0.92      5813

    accuracy                           0.90     12011
   macro avg       0.90      0.89      0.89     12011
weighted avg       0.90      0.90      0.90     12011

Using GridSearchCV, we tried out different tree settings and found the combination that worked best:

  • criterion: entropy
  • max_depth: 10
  • min_samples_split: 10
  • min_samples_leaf: 4

These limits keep the tree from growing too large, which helps avoid overfitting.

  • Best F1 score during tuning: 0.895

-Test accuracy after tuning: 90%

The tuned model gives more balanced and reliable predictions across the High, Medium, and Low engagement groups.

Next we create an SVM with the RBF kernel and define a small grid of values for C and gamma to try. GridSearchCV will test each combination and find the settings that give the best overall performance. We use weighted F1 as the scoring method so all engagement levels are treated fairly during tuning.

In [47]:
svm_model = SVC(kernel='rbf')
In [48]:
param_grid_svm = {
    'C': [0.1, 1, 10],
    'gamma': [0.01, 0.1, 1]
}
In [49]:
grid_svm = GridSearchCV(
    estimator=svm_model,
    param_grid=param_grid_svm,
    cv=3,
    scoring='f1_weighted',
    n_jobs=-1,
    verbose=1
)
In [50]:
grid_svm.fit(X_train_scaled, y_train)
Fitting 3 folds for each of 9 candidates, totalling 27 fits
Out[50]:
GridSearchCV(cv=3, estimator=SVC(), n_jobs=-1,
             param_grid={'C': [0.1, 1, 10], 'gamma': [0.01, 0.1, 1]},
             scoring='f1_weighted', verbose=1)
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.
GridSearchCV(cv=3, estimator=SVC(), n_jobs=-1,
             param_grid={'C': [0.1, 1, 10], 'gamma': [0.01, 0.1, 1]},
             scoring='f1_weighted', verbose=1)
SVC()
SVC()
In [51]:
print("Best parameters (SVM RBF):", grid_svm.best_params_)
print("Best cross-val F1 (weighted):", grid_svm.best_score_)
Best parameters (SVM RBF): {'C': 10, 'gamma': 0.01}
Best cross-val F1 (weighted): 0.8878940547882807

The grid search found that the best performance came from:

  • C = 10
  • gamma = 0.01

Using the best parameters found in the grid search, we retrain the SVM and test it on the unseen data:

In [52]:
best_svm = grid_svm.best_estimator_
In [53]:
y_test_pred_best_svm = best_svm.predict(X_test_scaled)
In [54]:
print("Test accuracy (Tuned SVM RBF):", accuracy_score(y_test, y_test_pred_best_svm))
print("\nTuned SVM RBF - Classification Report (Test Data)\n")
print(classification_report(y_test, y_test_pred_best_svm, target_names=label_encoder.classes_))
Test accuracy (Tuned SVM RBF): 0.8897677129298144

Tuned SVM RBF - Classification Report (Test Data)

              precision    recall  f1-score   support

        High       0.92      0.86      0.89      3101
         Low       0.89      0.83      0.86      3097
      Medium       0.88      0.94      0.91      5813

    accuracy                           0.89     12011
   macro avg       0.90      0.88      0.88     12011
weighted avg       0.89      0.89      0.89     12011

From the classification report, Medium engagement has the strongest recall, meaning the model correctly identifies most Medium-level players. High and Low engagement both show good precision and balanced F1-scores

In [55]:
y_test_proba_dt = best_dt.predict_proba(X_test)
dt_roc_auc = roc_auc_score(y_test, y_test_proba_dt, multi_class='ovr')
print("ROC-AUC (Tuned Decision Tree, OvR):", dt_roc_auc)
ROC-AUC (Tuned Decision Tree, OvR): 0.9321401413824558

A score of 0.93 is a strong result. It shows that the improved model is much better at seperating the three engagement classes

In [56]:
svm_proba = SVC(kernel='rbf', C=best_svm.C, gamma=best_svm.gamma, probability=True, random_state=42)
svm_proba.fit(X_train_scaled, y_train)
y_test_proba_svm = svm_proba.predict_proba(X_test_scaled)
svm_roc_auc = roc_auc_score(y_test, y_test_proba_svm, multi_class='ovr')
print("ROC-AUC (Tuned SVM RBF, OvR):", svm_roc_auc)
ROC-AUC (Tuned SVM RBF, OvR): 0.9417799327180895

To get the ROC-AUC score for the tuned SVM, we retrain it with the best parameters and enable probability outputs. 0.94 is the best score amongst all the models tested.

In [57]:
# Model Performance Comparison
results_summary = pd.DataFrame({
    'Model': ['Baseline DT', 'SVM Linear', 'Baseline SVM RBF', 'Tuned DT', 'Tuned SVM RBF'],
    'Test Accuracy': [0.8366, 0.8227, 0.8830, 0.8998, 0.8898],
    'Weighted F1': [0.84, 0.82, 0.88, 0.90, 0.89],
    'AUC-ROC': [
        dt_baseline_roc_auc, 
        svm_linear_roc_auc, 
        svm_rbf_baseline_roc_auc, 
        dt_roc_auc, 
        svm_roc_auc
    ]
})


print(results_summary.to_string(index=False))
           Model  Test Accuracy  Weighted F1  AUC-ROC
     Baseline DT         0.8366         0.84 0.871512
      SVM Linear         0.8227         0.82 0.914936
Baseline SVM RBF         0.8830         0.88 0.940642
        Tuned DT         0.8998         0.90 0.932140
   Tuned SVM RBF         0.8898         0.89 0.941780

The tuned Decision Tree achieves the best accuracy and weighted F1 score, showing it performs very well across all classes when tuned properly. The tuned RBF SVM, however, has the highest ROC-AUC, meaning it does the best job at separating the three engagement levels.

Overall, both tuned models perform strongly, with the Decision Tree offering more interpretability and the SVM offering slightly better ranking ability.

Confustion Matrix:

In [58]:
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

cm_dt = confusion_matrix(y_test, y_test_pred_best_dt)
disp_dt = ConfusionMatrixDisplay(confusion_matrix=cm_dt, 
                                  display_labels=label_encoder.classes_)
disp_dt.plot(ax=axes[0], cmap='Blues', values_format='d')
axes[0].set_title('Confusion Matrix: Tuned Decision Tree', fontsize=14, fontweight='bold')
axes[0].set_xlabel('Predicted Label', fontsize=12)
axes[0].set_ylabel('True Label', fontsize=12)

cm_svm = confusion_matrix(y_test, y_test_pred_best_svm)
disp_svm = ConfusionMatrixDisplay(confusion_matrix=cm_svm, 
                                   display_labels=label_encoder.classes_)
disp_svm.plot(ax=axes[1], cmap='Oranges', values_format='d')
axes[1].set_title('Confusion Matrix: Tuned SVM RBF', fontsize=14, fontweight='bold')
axes[1].set_xlabel('Predicted Label', fontsize=12)
axes[1].set_ylabel('True Label', fontsize=12)

plt.tight_layout()
plt.show()
No description has been provided for this image

These confusion matrices show how the tuned Decision Tree and tuned RBF SVM perform acros the three engagement levels. Both models correctly ident fy most Medium-engagement player, which is expected since this is the largest group and has clearer patterns.

High and Low engagement are where most mistakes happen. Both models sometimes confuse hese with Medium, showing that the boundaries between Medium–High and Medium–Low aae not lways clear in the data.

The Decision Tree is slightly better at identifying High and Low players, while th RBF SVM is slightly better at recognising Medium players. Overall, both models perform imilarly, with the SVM having a small edge in overall classseparation. s.

Next we'll use K-Means clustering to try and identify any patterns relating to engagement within the dataset

In [59]:
cluster_data = data.drop('EngagementLevel', axis=1)
In [60]:
cluster_data_encoded = pd.get_dummies(cluster_data, drop_first=True)
In [61]:
scaler_cluster = StandardScaler()
In [62]:
cluster_scaled = scaler_cluster.fit_transform(cluster_data_encoded)

First we'll cluster on all features:

In [63]:
inertia_values = []
silhouette_values = []
In [64]:
K = range(2, 10)
In [65]:
for k in K:
    kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
    labels = kmeans.fit_predict(cluster_scaled)
    inertia_values.append(kmeans.inertia_)
    silhouette_values.append(silhouette_score(cluster_scaled, labels))

Before choosing a number of clusters, we used two common methods to check whether the data naturally forms clear groups.

In [66]:
fig, axes = plt.subplots(1, 2, figsize=(12, 5))

axes[0].plot(K, inertia_values, marker='o')
axes[0].set_title("Elbow Method (All Features)")
axes[0].set_xlabel("Number of Clusters (k)")
axes[0].set_ylabel("Inertia")
axes[0].grid(True, alpha=0.3)

axes[1].plot(K, silhouette_values, marker='o', color='orange')
axes[1].set_title("Silhouette Score (All Features)")
axes[1].set_xlabel("Number of Clusters (k)")
axes[1].set_ylabel("Silhouette Score")
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()
No description has been provided for this image
In [67]:
kmeans_initial = KMeans(n_clusters=4, random_state=42, n_init=10)
cluster_labels = kmeans_initial.fit_predict(cluster_scaled)

data['Cluster_Initial'] = cluster_labels
In [68]:
print(data['Cluster_Initial'].value_counts().sort_index())
Cluster_Initial
0     7952
1    10411
2     8048
3    13623
Name: count, dtype: int64
In [69]:
cluster_summary = data.groupby('Cluster_Initial')[
    ['PlayTimeHours', 'SessionsPerWeek', 'AvgSessionDurationMinutes',
     'InGamePurchases', 'PlayerLevel', 'AchievementsUnlocked']
].mean().round(2)
print(cluster_summary)
                 PlayTimeHours  SessionsPerWeek  AvgSessionDurationMinutes  \
Cluster_Initial                                                              
0                        12.01             9.41                      93.74   
1                        11.96             9.44                      94.79   
2                        11.97             9.50                      95.05   
3                        12.12             9.52                      95.26   

                 InGamePurchases  PlayerLevel  AchievementsUnlocked  
Cluster_Initial                                                      
0                            0.2        49.43                 24.52  
1                            0.2        49.33                 24.60  
2                            0.2        49.89                 24.40  
3                            0.2        49.89                 24.55  

K-means was applied using k = 4, as supported by the silhouette scores.
However, the cluster profiles were almost identical, showing no real differences using all features. Too much noise obscured any patterns within the data.

Next, we'll explore k-means clustering with behavioural features only

In [70]:
behavioural_features = data[['PlayTimeHours', 'SessionsPerWeek', 'AvgSessionDurationMinutes']].copy()
scaler_behavioural = StandardScaler()
behavioural_scaled = scaler_behavioural.fit_transform(behavioural_features)
In [71]:
inertia_values_behavioural = []
silhouette_values_behavioural = []
K_range = range(2, 8)
In [72]:
for k in K_range:
    kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
    labels = kmeans.fit_predict(behavioural_scaled)
    inertia_values_behavioural.append(kmeans.inertia_)
    silhouette_values_behavioural.append(silhouette_score(behavioural_scaled, labels))
    
    print(f"k={k}: Silhouette Score = {silhouette_score(behavioural_scaled, labels):.4f}")
k=2: Silhouette Score = 0.2442
k=3: Silhouette Score = 0.2399
k=4: Silhouette Score = 0.2645
k=5: Silhouette Score = 0.2672
k=6: Silhouette Score = 0.2864
k=7: Silhouette Score = 0.2807
In [73]:
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
axes[0].plot(K_range, inertia_values_behavioural, marker='o', linewidth=2, markersize=8)
axes[0].set_xlabel('Number of Clusters (k)')
axes[0].set_ylabel('Inertia')
axes[0].set_title('Elbow Method (Behavioural Features)')
axes[0].grid(True, alpha=0.3)

axes[1].plot(K_range, silhouette_values_behavioural, marker='o', linewidth=2, markersize=8, color='orange')
axes[1].set_xlabel('Number of Clusters (k)')
axes[1].set_ylabel('Silhouette Score')
axes[1].set_title('Silhouette Score (Behavioral Features)')
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()
No description has been provided for this image

Even though k=6 had a slightly higher silhouette score, the improvement over k=4 was very small, and the elbow method still didn’t show a clear natural number of clusters. For simplicity and interpretability, k=4 was chosen for the behavioural clustering

In [74]:
kmeans_final = KMeans(n_clusters=4, random_state=42, n_init=10)
data['Final_Cluster'] = kmeans_final.fit_predict(behavioural_scaled)
In [75]:
cluster_profiles = data.groupby('Final_Cluster')[
    ['Age', 'PlayTimeHours', 'SessionsPerWeek', 'AvgSessionDurationMinutes',
     'InGamePurchases', 'PlayerLevel', 'AchievementsUnlocked']
].agg(['mean', 'std']).round(2)
print(cluster_profiles)
                 Age        PlayTimeHours       SessionsPerWeek        \
                mean    std          mean   std            mean   std   
Final_Cluster                                                           
0              32.17  10.05          5.38  3.37            9.38  5.29   
1              32.00  10.11         12.08  6.40           15.02  2.84   
2              31.87  10.01         12.14  6.35            4.01  2.81   
3              31.93  10.00         18.56  3.42            9.41  5.31   

              AvgSessionDurationMinutes        InGamePurchases       \
                                   mean    std            mean  std   
Final_Cluster                                                         
0                                 55.22  28.62             0.2  0.4   
1                                134.00  28.73             0.2  0.4   
2                                135.22  28.04             0.2  0.4   
3                                 54.07  27.98             0.2  0.4   

              PlayerLevel        AchievementsUnlocked         
                     mean    std                 mean    std  
Final_Cluster                                                 
0                   49.90  28.63                24.36  14.49  
1                   49.67  28.56                24.64  14.44  
2                   49.66  28.59                24.54  14.36  
3                   49.40  28.57                24.56  14.44  

The four clusters show different ways players spend their time in the game:

  • Cluster 0:Moderate number of sessions with shorter play times.
  • Cluster 1: Very frequent sessions with long play times.
  • Cluster 2: Fewer sessions, but each session lasts a long time.
  • Cluster 3: Very frequent sessions, but shorter play times.

Other features like purchases and level are almost the same across clusters, so the main differences come from playtime and session patterns.

In [76]:
cluster_sizes = data['Final_Cluster'].value_counts().sort_index()
print(cluster_sizes)
print("\nPercentage distribution:")
print((cluster_sizes / len(data) * 100).round(2))
Final_Cluster
0    10012
1    10110
2     9989
3     9923
Name: count, dtype: int64

Percentage distribution:
Final_Cluster
0    25.01
1    25.25
2    24.95
3    24.79
Name: count, dtype: float64

All four clusters are almost the same size, each making up around 25% of the players. This shows that the clustering didn’t create any very small or very large groups, and each behaviour pattern is represented fairly evenly in the dataset.

In [77]:
engagement_crosstab = pd.crosstab(data['Final_Cluster'], data['EngagementLevel'], normalize='index')
print(engagement_crosstab.round(3))
EngagementLevel   High    Low  Medium
Final_Cluster                        
0                0.057  0.341   0.602
1                0.838  0.032   0.130
2                0.071  0.320   0.610
3                0.059  0.342   0.599

Cluster 1 clearly stands out, with almost 84% of its players in the High engagement group. The other three clusters have very similar engagement distributions, each with around 60% Medium engagement and only a small proportion of High engagement playersurs.

In [78]:
for cluster_id in sorted(data['Final_Cluster'].unique()):
    cluster_data_subset = data[data['Final_Cluster'] == cluster_id]
    print(f"\nCluster {cluster_id}: (n={len(cluster_data_subset)}, {len(cluster_data_subset)/len(data)*100:.1f}%)")
    print(f"  Avg Play Time: {cluster_data_subset['PlayTimeHours'].mean():.2f} hrs perweek")
    print(f"  Avg Sessions: {cluster_data_subset['SessionsPerWeek'].mean():.2f} sessions per week")
    print(f"  Avg Session Duration: {cluster_data_subset['AvgSessionDurationMinutes'].mean():.2f} minutes")
    print(f"  In-Game Purchases: {cluster_data_subset['InGamePurchases'].mean():.2f}")
    high_eng_pct = (cluster_data_subset['EngagementLevel']=='High').sum()/len(cluster_data_subset)*100
    print(f"  High Engagement %: {high_eng_pct:.1f}%")

    if cluster_id == 0:
        label = "MODERATE FREQUENCY, SHORT DURATION"
    elif cluster_id == 1:
        label = "HIGH FREQUENCY, EXTENDED DURATION"
    elif cluster_id == 2:
        label = "LOW FREQUENCY, EXTENDED DURATION"
    else:
        label = "MODERATE FREQUENCY, SHORT DURATION (HIGH VOLUME)"
    print(f"  Label: {label}")
Cluster 0: (n=10012, 25.0%)
  Avg Play Time: 5.38 hrs perweek
  Avg Sessions: 9.38 sessions per week
  Avg Session Duration: 55.22 minutes
  In-Game Purchases: 0.20
  High Engagement %: 5.7%
  Label: MODERATE FREQUENCY, SHORT DURATION

Cluster 1: (n=10110, 25.3%)
  Avg Play Time: 12.08 hrs perweek
  Avg Sessions: 15.02 sessions per week
  Avg Session Duration: 134.00 minutes
  In-Game Purchases: 0.20
  High Engagement %: 83.8%
  Label: HIGH FREQUENCY, EXTENDED DURATION

Cluster 2: (n=9989, 25.0%)
  Avg Play Time: 12.14 hrs perweek
  Avg Sessions: 4.01 sessions per week
  Avg Session Duration: 135.22 minutes
  In-Game Purchases: 0.20
  High Engagement %: 7.1%
  Label: LOW FREQUENCY, EXTENDED DURATION

Cluster 3: (n=9923, 24.8%)
  Avg Play Time: 18.56 hrs perweek
  Avg Sessions: 9.41 sessions per week
  Avg Session Duration: 54.07 minutes
  In-Game Purchases: 0.20
  High Engagement %: 5.9%
  Label: MODERATE FREQUENCY, SHORT DURATION (HIGH VOLUME)

Each cluster shows a different play style:

  • Cluster 0: Moderate number of sessions with shorter play times. Only 6% are highly engaged.
  • Cluster 1: Frequent sessions and long play times. This is the clear high-engagement group (84%).
  • Cluster 2: Few sessions per week but long sessions. High engagement is still low (7%).
  • Cluster 3: Similar to Cluster 0 but with more total hours played. High engagement remains low (6%).

Overall, only one behaviour pattern (Cluster 1) is strongly linked to high engagement. The other clusters behave differently but show similar, low high-engagement levels.

In [79]:
gender_profile = data.groupby('Gender')[
    ['PlayTimeHours', 'SessionsPerWeek', 'InGamePurchases', 'PlayerLevel']
].mean().round(2)
In [80]:
print(gender_profile)
        PlayTimeHours  SessionsPerWeek  InGamePurchases  PlayerLevel
Gender                                                              
Female          11.97             9.52              0.2        49.42
Male            12.06             9.44              0.2        49.81
In [81]:
gender_engagement = pd.crosstab(data['Gender'], data['EngagementLevel'], normalize='index')
print(gender_engagement.round(3))
EngagementLevel   High    Low  Medium
Gender                               
Female           0.261  0.256   0.483
Male             0.256  0.259   0.485
In [82]:
location_profile = data.groupby('Location')[
    ['PlayTimeHours', 'SessionsPerWeek', 'InGamePurchases', 'PlayerLevel']
].mean().round(2)
print(location_profile)
          PlayTimeHours  SessionsPerWeek  InGamePurchases  PlayerLevel
Location                                                              
Asia              12.19             9.49             0.21        49.84
Europe            11.93             9.48             0.20        49.24
Other             12.06             9.44             0.19        50.18
USA               12.00             9.46             0.20        49.74
In [83]:
location_engagement = pd.crosstab(data['Location'], data['EngagementLevel'], normalize='index')
print(location_engagement.round(3))
EngagementLevel   High    Low  Medium
Location                             
Asia             0.263  0.254   0.483
Europe           0.256  0.262   0.483
Other            0.257  0.263   0.480
USA              0.258  0.255   0.487
In [84]:
difficulty_profile = data.groupby('GameDifficulty')[
    ['PlayTimeHours', 'SessionsPerWeek', 'InGamePurchases', 'PlayerLevel']
].mean().round(2)
print(difficulty_profile)
                PlayTimeHours  SessionsPerWeek  InGamePurchases  PlayerLevel
GameDifficulty                                                              
Easy                    12.01             9.44              0.2        49.51
Hard                    12.04             9.50              0.2        49.97
Medium                  12.03             9.51              0.2        49.69
In [85]:
difficulty_engagement = pd.crosstab(data['GameDifficulty'], data['EngagementLevel'], normalize='index')
print(difficulty_engagement.round(3))
EngagementLevel   High    Low  Medium
GameDifficulty                       
Easy             0.256  0.260   0.483
Hard             0.264  0.259   0.477
Medium           0.257  0.253   0.489
In [86]:
genre_profile = data.groupby('GameGenre')[
    ['PlayTimeHours', 'SessionsPerWeek', 'InGamePurchases', 'PlayerLevel']
].mean().round(2)
print(genre_profile)
            PlayTimeHours  SessionsPerWeek  InGamePurchases  PlayerLevel
GameGenre                                                               
Action              12.16             9.47             0.19        49.80
RPG                 12.01             9.41             0.20        49.43
Simulation          11.90             9.44             0.20        49.25
Sports              11.97             9.50             0.20        49.89
Strategy            12.08             9.54             0.21        49.90
In [87]:
genre_engagement = pd.crosstab(data['GameGenre'], data['EngagementLevel'], normalize='index')
print(genre_engagement.round(3))
EngagementLevel   High    Low  Medium
GameGenre                            
Action           0.257  0.256   0.487
RPG              0.250  0.264   0.486
Simulation       0.264  0.262   0.475
Sports           0.259  0.258   0.483
Strategy         0.262  0.250   0.488
In [88]:
gender_cluster = pd.crosstab(data['Final_Cluster'], data['Gender'], normalize='columns')
print(gender_cluster.round(3))
Gender         Female   Male
Final_Cluster               
0               0.249  0.251
1               0.256  0.250
2               0.249  0.250
3               0.246  0.249
In [89]:
location_cluster = pd.crosstab(data['Final_Cluster'], data['Location'], normalize='columns')
print(location_cluster.round(3))
Location        Asia  Europe  Other    USA
Final_Cluster                             
0              0.244   0.257  0.252  0.248
1              0.253   0.251  0.249  0.254
2              0.250   0.248  0.254  0.249
3              0.253   0.244  0.245  0.249
In [90]:
genre_cluster = pd.crosstab(data['Final_Cluster'], data['GameGenre'], normalize='columns')
print(genre_cluster.round(3))
GameGenre      Action    RPG  Simulation  Sports  Strategy
Final_Cluster                                             
0               0.244  0.253       0.248   0.254     0.251
1               0.252  0.243       0.254   0.259     0.255
2               0.249  0.248       0.250   0.248     0.253
3               0.255  0.256       0.248   0.239     0.241

The above data shows that demographics doesn't have create any meaningful patterns. Therefore, the company should focus on behavioural features, as highlighted further above.

In [ ]: