Thomas Sellwood (2215661) The aim of this script is to perform data analystics and machine learning methods to discover behaovioural patterns among players and then to predict the engagement level of players utilising support vector machines and decision trees.

InĀ [103]:
# Load required libraries
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

# To sclae data using the z-score
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import normalize
from sklearn.decomposition import PCA

# Algorithms to use
from sklearn.cluster import KMeans
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import GridSearchCV
from sklearn.cluster import DBSCAN

# Evaluation metrics
from sklearn import metrics
from sklearn.metrics import classification_report, confusion_matrix, silhouette_score, recall_score, precision_recall_curve, accuracy_score, roc_auc_score

# Ignore warnings
import warnings
warnings.filterwarnings('ignore')
InĀ [104]:
# Create function to evaluate model performance
def metrics_score(actual, predicted):
    print("\nClassification Report:")
    print(classification_report(actual, predicted))

    cm = confusion_matrix(actual, predicted)
    plt.figure(figsize=(8, 6))
    sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=engagement_map.keys(), yticklabels=engagement_map.keys())
    plt.ylabel('Actual')
    plt.xlabel('Predicted')
    plt.title('Confusion Matrix')
    plt.show()
InĀ [105]:
# Load dataset
data = pd.read_csv('online_gaming_behavior_dataset.csv')

# Display first few rows of the dataset
data.head(25)
Out[105]:
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
5 9005 37 Male Europe RPG 20.561855 0 Easy 2 81 74 22 Low
6 9006 25 Male USA Action 9.752716 0 Hard 1 50 13 2 Low
7 9007 25 Female Asia RPG 4.401729 0 Medium 10 48 27 23 Medium
8 9008 38 Female Europe Simulation 18.152733 0 Easy 5 101 23 41 Medium
9 9009 38 Female Other Sports 23.942772 0 Easy 13 95 99 36 High
10 9010 17 Male USA Strategy 4.829916 0 Hard 8 95 14 12 High
11 9011 36 Female Asia Simulation 5.535981 1 Easy 16 124 62 31 High
12 9012 16 Male USA Sports 18.776234 1 Easy 9 18 52 32 High
13 9013 38 Female USA Strategy 8.701959 0 Easy 0 156 33 47 Low
14 9014 44 Male USA Simulation 17.975200 0 Easy 8 41 98 1 Low
15 9015 16 Male Europe RPG 7.951511 0 Medium 10 156 58 24 High
16 9016 35 Male Asia Strategy 17.887898 0 Easy 16 154 62 4 High
17 9017 47 Male USA RPG 17.272113 0 Medium 2 131 13 9 Low
18 9018 26 Female Asia Action 19.416079 0 Easy 3 135 77 25 Medium
19 9019 36 Female Asia RPG 19.338826 0 Easy 4 56 21 30 Low
20 9020 39 Female USA Sports 1.722581 0 Easy 2 177 34 37 Medium
21 9021 41 Female USA Sports 16.167690 0 Easy 18 159 36 33 High
22 9022 42 Male USA Sports 10.448126 0 Medium 2 120 81 5 Low
23 9023 30 Male Europe Strategy 17.959835 0 Easy 14 117 40 19 High
24 9024 29 Male Europe Sports 22.564723 0 Medium 12 161 1 22 High
InĀ [65]:
# Display info about the dataset
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

After reading in the data, I can see that there are 13 columns, most are numerical columns but there are some that are categorical columns such as game genre. There are no null values within the data.

This section covers examines the data set itself, I get a breakdown of the number of individual values contained in each column from data.info(), then I get summary statistics for each column from the data.describe() function.

InĀ [66]:
# Check for unique values within each column
data.nunique()
Out[66]:
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Ā [106]:
# Summary statistics of the dataset
data.describe().round(2)
Out[106]:
PlayerID Age PlayTimeHours InGamePurchases SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked
count 40034.00 40034.00 40034.00 40034.0 40034.00 40034.00 40034.00 40034.00
mean 29016.50 31.99 12.02 0.2 9.47 94.79 49.66 24.53
std 11556.96 10.04 6.91 0.4 5.76 49.01 28.59 14.43
min 9000.00 15.00 0.00 0.0 0.00 10.00 1.00 0.00
25% 19008.25 23.00 6.07 0.0 4.00 52.00 25.00 12.00
50% 29016.50 32.00 12.01 0.0 9.00 95.00 49.00 25.00
75% 39024.75 41.00 17.96 0.0 14.00 137.00 74.00 37.00
max 49033.00 49.00 24.00 1.0 19.00 179.00 99.00 49.00

From here I break up the data into the columns containing numerical data and categorical data.

InĀ [107]:
# Separate numerical and categorical columns
num_cols = data.select_dtypes(include=np.number).columns.tolist()
cat_cols = data.select_dtypes(include=['object']).columns.tolist()

# Display numerical columns
print("Numerical Columns:")
print(num_cols)

# Display categorical columns
print("\nCategorical Columns:")
print(cat_cols)
Numerical Columns:
['PlayerID', 'Age', 'PlayTimeHours', 'InGamePurchases', 'SessionsPerWeek', 'AvgSessionDurationMinutes', 'PlayerLevel', 'AchievementsUnlocked']

Categorical Columns:
['Gender', 'Location', 'GameGenre', 'GameDifficulty', 'EngagementLevel']
InĀ [69]:
# Summary statistics for numerical columns
data[num_cols].describe().T
Out[69]:
count mean std min 25% 50% 75% max
PlayerID 40034.0 29016.500000 11556.964675 9000.000000 19008.250000 29016.500000 39024.750000 49033.000000
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
InGamePurchases 40034.0 0.200854 0.400644 0.000000 0.000000 0.000000 0.000000 1.000000
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

Below I start the exploratory data analysis by producing histograms of the dataset.

InĀ [108]:
# Create Histograms for the numerical columns
data[num_cols].hist(bins=15, figsize=(15, 10), layout=(4, 4))
plt.show()
No description has been provided for this image

The data shows how different player-related features are distributed in the dataset. Player ages range evenly from young adults to older adults, and gameplay features such as playtime, session duration, player level, and achievements appear spread out fairly evenly across their ranges. Sessions per week also varies across players, while in-game purchases show that most players do not spend money, with only a small number making purchases. Overall, the histograms show a wide range of player behaviours, with most features evenly distributed and only in-game purchases showing a strong imbalance.

InĀ [109]:
# Analysis Kurtosis and Skewness
kurtosis_values = data[num_cols].kurtosis()
skewness_values = data[num_cols].skew()

print("\nKurtosis Values:")
print(kurtosis_values)

print("\nSkewness Values:")
print(skewness_values)
Kurtosis Values:
PlayerID                    -1.200000
Age                         -1.192417
PlayTimeHours               -1.195706
InGamePurchases              0.230249
SessionsPerWeek             -1.206566
AvgSessionDurationMinutes   -1.199556
PlayerLevel                 -1.199738
AchievementsUnlocked        -1.199623
dtype: float64

Skewness Values:
PlayerID                     0.000000
Age                         -0.004466
PlayTimeHours               -0.002225
InGamePurchases              1.493398
SessionsPerWeek              0.015517
AvgSessionDurationMinutes   -0.005632
PlayerLevel                  0.018754
AchievementsUnlocked        -0.005136
dtype: float64

Again much like the data in histograms most features kurtosis is negative showing that the distributions are much flatter and more so uniform. This indicates that the features do not have strong peaks or tails, every value occurs with similar frequency. Again much like the kurtosis most of the skewness for the features has values close to 0 showing that the distributions are symmetrical.

The only feature that doesn't show this is InGamPurchases, the majority of players have 0 purchases with a small number making purchases, this is shown by the skewness with the strong positive value.

InĀ [110]:
# Analysing the percentage split in categorical columns
for col in cat_cols:
    print(data[col].value_counts(normalize=True) * 100)
    print('*' * 40)
Gender
Male      59.84663
Female    40.15337
Name: proportion, dtype: float64
****************************************
Location
USA       39.966029
Europe    29.984513
Asia      20.220313
Other      9.829145
Name: proportion, dtype: float64
****************************************
GameGenre
Sports        20.102913
Action        20.080432
Strategy      20.012989
Simulation    19.940551
RPG           19.863116
Name: proportion, dtype: float64
****************************************
GameDifficulty
Easy      49.995004
Medium    30.001998
Hard      20.002997
Name: proportion, dtype: float64
****************************************
EngagementLevel
Medium    48.393865
High      25.818055
Low       25.788080
Name: proportion, dtype: float64
****************************************

The categorical distributions show that the dataset contains a higher proportion of male players than female players, with most players coming from the USA and Europe. Game genres are almost perfectly balanced across Sports, Action, Strategy, Simulation and RPG. Difficulty preference leans heavily toward Easy, followed by Medium and Hard, which aligns with typical casual gaming behaviour. Engagement levels are also well balanced, with Medium being the most common and High and Low represented almost equally. Overall, the data reflects a broad and diverse player base with a few natural imbalances that are typical in gaming contexts.

InĀ [111]:
# Mean of numerical variables grouped by engagment level

data.groupby(['EngagementLevel'])[num_cols].mean().round(0)
Out[111]:
PlayerID Age PlayTimeHours InGamePurchases SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked
EngagementLevel
High 29031.0 32.0 12.0 0.0 14.0 132.0 51.0 25.0
Low 29093.0 32.0 12.0 0.0 5.0 67.0 46.0 23.0
Medium 28968.0 32.0 12.0 0.0 10.0 90.0 51.0 25.0

The table shows the average values of each numerical feature for players with High, Medium, and Low engagement levels. Across all three groups, the averages for Age, PlayTimeHours, InGamePurchases, PlayerLevel, and AchievementsUnlocked are almost identical, suggesting these features do not strongly differentiate engagement levels in this dataset. The only meaningful differences appear in SessionsPerWeek and AvgSessionDurationMinutes. High-engagement players have the most sessions per week (14) and the longest session durations (132 minutes), while low-engagement players have fewer weekly sessions (5) and shorter sessions (67 minutes). Medium engagement sits roughly in between. This pattern indicates that engagement level is primarily influenced by how often and how long players play, rather than their age, purchases, or skill-related metrics.

InĀ [112]:
# Create a pivot table and chart to analyze the relationship between GameDifficulty and EngagementLevel

game_difficulty_engagement=data.pivot_table(index='GameDifficulty', columns="EngagementLevel", values="PlayerID",aggfunc="count")
game_difficulty_engagement.plot(kind='bar', figsize=(10, 6), colormap='viridis')
plt.title('Engagement Level by Game Difficulty')
plt.xlabel('Game Difficulty')
plt.ylabel('Number of Players')
plt.legend(title='Engagement Level', bbox_to_anchor=(1.02, 1), loc='upper left')
plt.tight_layout()
plt.show()
No description has been provided for this image

The chart shows that Medium-engagement players are the largest group across all game difficulties, while High and Low engagement remain similar in number. Easy and Medium difficulties attract the most players overall, whereas Hard has fewer players but follows the same pattern. Overall, game difficulty doesn’t strongly separate engagement levels—Medium engagement consistently dominates regardless of difficulty.

InĀ [113]:
# Average Player Level by Engagement Level with standard deviation
avg_player_enagaementlevel = data.pivot_table(index='EngagementLevel', values='PlayerLevel', aggfunc=("mean",'std'),observed=True)
avg_player_enagaementlevel.round(0)
Out[113]:
mean std
EngagementLevel
High 51.0 29.0
Low 46.0 28.0
Medium 51.0 29.0

The table compares the mean and standard deviation of PlayerLevel across engagement levels. High- and Medium-engagement players both have an average value of 51 with a standard deviation of 29, indicating very similar behaviour. Low-engagement players have a slightly lower mean of 46 and a slightly smaller spread. Overall, the differences between groups are small, suggesting this feature does not strongly distinguish between engagement levels.

InĀ [114]:
# Player In game pruchases by Enagagement Level
in_game_purchases = data[data["InGamePurchases"]==True].groupby("EngagementLevel")["PlayerID"].count()
in_game_purchases.plot(kind='bar', figsize=(8, 5), color='skyblue')
plt.title('In-Game Purchases by Engagement Level')
plt.xlabel('Engagement Level')
plt.ylabel('Number of Players Making Purchases')
plt.xticks(rotation=0)
plt.show()
No description has been provided for this image

The chart shows that Medium-engagement players make the most in-game purchases, significantly more than both High- and Low-engagement players. High- and Low-engagement groups have almost the same number of purchasers, with only a small difference between them. Overall, this suggests that players with moderate engagement are the most likely to spend money in the game, while highly engaged and minimally engaged players purchase at similar, lower rates.

InĀ [115]:
# Engagmenet Level by Playtime Hours
playtime_engagement = data.pivot_table(index='EngagementLevel', values='PlayTimeHours', aggfunc=('mean','std'),observed=True)
playtime_engagement.round(2)
Out[115]:
mean std
EngagementLevel
High 12.07 6.91
Low 12.10 6.89
Medium 11.96 6.93

The table compares the mean and standard deviation of PlayTimeHours across engagement levels. All engagement levels are almost identical with the standard deviations being nearly all the same for the groups. This demonstartes that PlayTimeHours is not a strong distinguishing factor for engagement.

InĀ [78]:
# Average Session Time by Engagement Level with standard deviation
avg_playtime_engagement = data.pivot_table(index='EngagementLevel', values='AvgSessionDurationMinutes', aggfunc=('mean','std'),observed=True)
avg_playtime_engagement.round(2)
Out[78]:
mean std
EngagementLevel
High 131.92 34.07
Low 66.88 48.59
Medium 89.86 43.72
InĀ [79]:
# Gaming Hours Weekly by Engagement Level
data["GamingHoursWeekly"] = data["SessionsPerWeek"] * data["AvgSessionDurationMinutes"] / 60
weekly_gaming_engagement = data.pivot_table(index='EngagementLevel', values='GamingHoursWeekly', aggfunc=('mean','std'),observed=True)
weekly_gaming_engagement.round(2)
Out[79]:
mean std
EngagementLevel
High 31.34 10.92
Low 3.66 6.26
Medium 12.24 6.20

The average session duration differs noticeably across engagement levels: High-engagement players have much longer sessions, Medium players sit in the middle, and Low-engagement players have the shortest sessions. This pattern becomes even clearer when looking at total weekly gaming hours. High-engagement players average over 31 hours per week, Medium-engagement players average about 12 hours, and Low-engagement players only around 3–4 hours. These results show that session length and total weekly gaming time are the strongest behavioural indicators of engagement, increasing consistently from low to medium to high engagement.

InĀ [80]:
# Visualise the GamingHours Weekly by Engagement Level
plt.figure(figsize=(14,9))
sns.barplot(data = data, x="EngagementLevel", y="GamingHoursWeekly", errorbar="sd", palette="viridis", hue="EngagementLevel")
plt.title("Average Weekly Gaming Hours by Engagement Level")
plt.xlabel("Engagement Level")
plt.ylabel("Total Gaming Hours (Weekly)")
plt.show()
No description has been provided for this image

The chart shows that High-engagement players spend by far the most time gaming each week, averaging over 30 hours, while Medium-engagement players spend around 12 hours. Low-engagement players spend very little time gaming, averaging under 4 hours per week. The pattern clearly demonstrates that weekly gaming hours strongly increase with engagement level, making it one of the clearest behavioural indicators distinguishing the three groups.

InĀ [81]:
# Engagement Level by Gender
gender_engagement = data.pivot_table(index='EngagementLevel', columns='Gender', values='PlayerID', aggfunc='count', observed=True)
sns.countplot(data = data, x="EngagementLevel", palette="viridis", hue="Gender")
plt.title('Engagement Level by Gender')
plt.xlabel('Engagement Level')
plt.ylabel('Number of Players')
plt.legend(title='Gender', bbox_to_anchor=(1.02, 1), loc='upper left')
plt.tight_layout()
plt.show()
No description has been provided for this image

The chart shows that for every engagement level, there are more male players than female players. Medium engagement has the largest number of players overall for both genders. High and Low engagement levels show similar patterns, with males consistently forming the larger share. Overall, gender does not change the engagement pattern—both male and female players are most likely to fall into the Medium engagement category.

InĀ [82]:
# Engagement Level Per Location
region_engagement = data.pivot_table(index='EngagementLevel', columns='Location', values='PlayerID', aggfunc='count', observed=True)
region_engagement.round(0)
Out[82]:
Location Asia Europe Other USA
EngagementLevel
High 2131 3068 1010 4127
Low 2058 3143 1036 4087
Medium 3906 5793 1889 7786

The table shows that across all regions, Medium engagement has the largest number of players, followed by High and Low, which are very similar in size. The USA has the highest counts in every engagement level, followed by Europe, with Asia and Other having smaller but comparable numbers. The pattern is consistent across regions: Medium engagement is the largest, and no region shows a unique or unusual engagement trend. Overall, engagement levels appear consistent regardless of geographic location.

InĀ [83]:
# Engagement level by Achievements Unlocked
achievements_engagement = data.pivot_table(index='EngagementLevel', values='AchievementsUnlocked', aggfunc=('mean','std'),observed=True)
achievements_engagement.round(2)
Out[83]:
mean std
EngagementLevel
High 25.10 14.42
Low 22.66 14.19
Medium 25.22 14.48
InĀ [84]:
# Visualise Engagement Level by Achievements Unlocked
plt.figure(figsize=(14,9))
sns.barplot(data = data, x="EngagementLevel", y="AchievementsUnlocked", errorbar="sd", palette="viridis", hue="EngagementLevel")
plt.title("Average Achievements Unlocked by Engagement Level")
plt.xlabel("Engagement Level")
plt.ylabel("Average Achievements Unlocked")
plt.show()
No description has been provided for this image

The table and chart shows that across engagement levels High and Medium engagement players unlock the most achievements around 25 with low engagement players following behind with 22. The standard deviation is nearly identical across all the groups. Overall achievments unlocked do not vary much suggesting that this feature is not a strong indicator.

Below this is where the data is prepared for modelling

InĀ [85]:
# Prepare data for modelling
model_data = data.copy()

# Drop non-predictive ID column
model_data = model_data.drop(columns=["PlayerID"])

# Identify categorical variables to encode
categorical_cols = ['Gender', 'Location', 'GameGenre', 'GameDifficulty']

# Create dummy variables (one-hot encoding)
model_data = pd.get_dummies(model_data, columns=categorical_cols, drop_first=True)

# Encode target variable (EngagementLevel) #

engagement_map = {'Low': 0, 'Medium': 1, 'High': 2}
model_data['EngagementLevel'] = model_data['EngagementLevel'].map(engagement_map)


# Check the result
print(model_data.head())
   Age  PlayTimeHours  InGamePurchases  SessionsPerWeek  \
0   43      16.271119                0                6   
1   29       5.525961                0                5   
2   22       8.223755                0               16   
3   35       5.265351                1                9   
4   33      15.531945                0                2   

   AvgSessionDurationMinutes  PlayerLevel  AchievementsUnlocked  \
0                        108           79                    25   
1                        144           11                    10   
2                        142           35                    41   
3                         85           57                    47   
4                        131           95                    37   

   EngagementLevel  GamingHoursWeekly  Gender_Male  Location_Europe  \
0                1          10.800000         True            False   
1                1          12.000000        False            False   
2                2          37.866667        False            False   
3                1          12.750000         True            False   
4                1           4.366667         True             True   

   Location_Other  Location_USA  GameGenre_RPG  GameGenre_Simulation  \
0            True         False          False                 False   
1           False          True          False                 False   
2           False          True          False                 False   
3           False          True          False                 False   
4           False         False          False                 False   

   GameGenre_Sports  GameGenre_Strategy  GameDifficulty_Hard  \
0             False                True                False   
1             False                True                False   
2              True               False                False   
3             False               False                False   
4             False               False                False   

   GameDifficulty_Medium  
0                   True  
1                   True  
2                  False  
3                  False  
4                   True  

The data is converted to numerical values ready for working with the SVM, and the PlayerID column is dropped as this isn't required.

InĀ [86]:
# Seperate target variable and features
Y = model_data.EngagementLevel
X = model_data.drop('EngagementLevel', axis=1)
InĀ [87]:
# Separate training and testing data
X_train, X_test, Y_train, Y_test = train_test_split(X, Y, test_size=0.2, random_state=1, stratify=Y)
InĀ [88]:
# Scale the data

sc = StandardScaler()

# Fit transformer on training data
X_train_scaled = sc.fit_transform(X_train)
X_train_scaled = pd.DataFrame(X_train_scaled, columns=X_train.columns)

# Transform testing data
X_test_scaled = sc.transform(X_test)
X_test_scaled = pd.DataFrame(X_test_scaled, columns=X_test.columns)
InĀ [89]:
# Fit SVM model with different kernels and evaluate performance

kernel_configs = [
    {"name": "linear",  "kwargs": {"kernel": "linear"}},
    {"name": "rbf",     "kwargs": {"kernel": "rbf", "gamma": "scale"}},
    {"name": "poly",    "kwargs": {"kernel": "poly", "degree": 3, "coef0": 0.0}},
    {"name": "sigmoid", "kwargs": {"kernel": "sigmoid", "coef0": 0.0}},
]

results = []

for cfg in kernel_configs:
    
    # Fit the model
    model = SVC(probability=True, **cfg["kwargs"]).fit(X_train_scaled, Y_train)

    # TRAIN metrics 
    y_pred_train_labels = model.predict(X_train_scaled)
    print(f"\n####### {cfg['name'].upper()} — TRAIN #######")
    metrics_score(Y_train, y_pred_train_labels)  

    y_pred_train_proba = model.predict_proba(X_train_scaled)

    # TEST metrics
    y_pred_test_labels = model.predict(X_test_scaled)
    print(f"\n####### {cfg['name'].upper()} — TEST #######")
    metrics_score(Y_test, y_pred_test_labels)    

    y_pred_test_proba = model.predict_proba(X_test_scaled)

    # Numeric metrics
    acc = accuracy_score(Y_test, y_pred_test_labels)
    auc_roc = roc_auc_score(Y_test, 
                            y_pred_test_proba,
                            multi_class='ovr', 
                            average='macro')

    print(f"Test Accuracy: {acc:.4f}")
    print(f"Test AUC-ROC: {auc_roc:.4f}")

    results.append({
        "kernel": cfg["name"],
        "accuracy": acc,
        "auc_roc": auc_roc,
        "model": model
    })

# Convert dict to data frame
results = pd.DataFrame(results)

display(results)
####### LINEAR — TRAIN #######

Classification Report:
              precision    recall  f1-score   support

           0       0.86      0.87      0.87      8259
           1       0.89      0.91      0.90     15499
           2       0.91      0.87      0.89      8269

    accuracy                           0.89     32027
   macro avg       0.89      0.88      0.89     32027
weighted avg       0.89      0.89      0.89     32027

No description has been provided for this image
####### LINEAR — TEST #######

Classification Report:
              precision    recall  f1-score   support

           0       0.86      0.86      0.86      2065
           1       0.89      0.91      0.90      3875
           2       0.91      0.87      0.89      2067

    accuracy                           0.89      8007
   macro avg       0.89      0.88      0.88      8007
weighted avg       0.89      0.89      0.89      8007

No description has been provided for this image
Test Accuracy: 0.8885
Test AUC-ROC: 0.9406

####### RBF — TRAIN #######

Classification Report:
              precision    recall  f1-score   support

           0       0.92      0.88      0.90      8259
           1       0.91      0.95      0.93     15499
           2       0.93      0.88      0.91      8269

    accuracy                           0.92     32027
   macro avg       0.92      0.90      0.91     32027
weighted avg       0.92      0.92      0.92     32027

No description has been provided for this image
####### RBF — TEST #######

Classification Report:
              precision    recall  f1-score   support

           0       0.90      0.85      0.87      2065
           1       0.89      0.94      0.91      3875
           2       0.92      0.86      0.89      2067

    accuracy                           0.90      8007
   macro avg       0.90      0.89      0.89      8007
weighted avg       0.90      0.90      0.90      8007

No description has been provided for this image
Test Accuracy: 0.8982
Test AUC-ROC: 0.9422

####### POLY — TRAIN #######

Classification Report:
              precision    recall  f1-score   support

           0       0.92      0.85      0.88      8259
           1       0.89      0.95      0.92     15499
           2       0.92      0.86      0.89      8269

    accuracy                           0.90     32027
   macro avg       0.91      0.89      0.90     32027
weighted avg       0.90      0.90      0.90     32027

No description has been provided for this image
####### POLY — TEST #######

Classification Report:
              precision    recall  f1-score   support

           0       0.90      0.83      0.86      2065
           1       0.87      0.93      0.90      3875
           2       0.90      0.84      0.87      2067

    accuracy                           0.88      8007
   macro avg       0.89      0.87      0.88      8007
weighted avg       0.88      0.88      0.88      8007

No description has been provided for this image
Test Accuracy: 0.8815
Test AUC-ROC: 0.9393

####### SIGMOID — TRAIN #######

Classification Report:
              precision    recall  f1-score   support

           0       0.64      0.64      0.64      8259
           1       0.75      0.75      0.75     15499
           2       0.78      0.80      0.79      8269

    accuracy                           0.73     32027
   macro avg       0.73      0.73      0.73     32027
weighted avg       0.73      0.73      0.73     32027

No description has been provided for this image
####### SIGMOID — TEST #######

Classification Report:
              precision    recall  f1-score   support

           0       0.64      0.65      0.65      2065
           1       0.76      0.76      0.76      3875
           2       0.80      0.80      0.80      2067

    accuracy                           0.74      8007
   macro avg       0.73      0.74      0.73      8007
weighted avg       0.74      0.74      0.74      8007

No description has been provided for this image
Test Accuracy: 0.7397
Test AUC-ROC: 0.8504
kernel accuracy auc_roc model
0 linear 0.888473 0.940608 SVC(kernel='linear', probability=True)
1 rbf 0.898214 0.942176 SVC(probability=True)
2 poly 0.881479 0.939289 SVC(kernel='poly', probability=True)
3 sigmoid 0.739728 0.850431 SVC(kernel='sigmoid', probability=True)

The Linear and RBF SVM models perform the best, both achieving high accuracy (around 0.89–0.90) and strong AUC-ROC scores around 0.94, with consistently low misclassification. The Polynomial kernel performs slightly worse but is still reliable. The Sigmoid kernel performs poorly, with much lower accuracy (0.74) and higher confusion across classes. Overall, RBF is the strongest model, followed closely by Linear, while Polynomial is acceptable and Sigmoid is not suitable for this task.

InĀ [90]:
# Use grid search to find best hyperparameters for SVC from best two models
svc = SVC(probability=True)
param_grid = [
    {'kernel': ['linear'], 'C': [0.1, 1, 10, 100]},
    {'kernel': ['rbf'], 'C': [0.1, 1, 10, 100], 'gamma': [0.001, 0.01, 0.1, 1]},
]   

grid_search = GridSearchCV(svc, param_grid, cv=3, scoring='accuracy', n_jobs=-1)
grid_search.fit(X_train_scaled, Y_train)

print("Best Hyperparameters:", grid_search.best_params_)
Best Hyperparameters: {'C': 10, 'gamma': 0.01, 'kernel': 'rbf'}

After the inital round of hyperparameter tuning and finding the right models, a grid search was used to further tune the hyperparameters. After running the grid search for for three folds the best parameters the best parameters are decided on scoring by accuracy. The ouput is as follows:

{'C': 10, 'gamma': 0.01, 'kernel': 'rbf'}

InĀ [91]:
# Fit the model
model_tuned = SVC(probability=True, **grid_search.best_params_).fit(X_train_scaled, Y_train)

# TRAIN metrics 
y_pred_train_labels = model_tuned.predict(X_train_scaled)
print(f"\n####### TRAIN #######")
metrics_score(Y_train, y_pred_train_labels)  

y_pred_train_proba = model_tuned.predict_proba(X_train_scaled)

# TEST metrics
y_pred_test_labels = model_tuned.predict(X_test_scaled)
print(f"\n####### TEST #######")
metrics_score(Y_test, y_pred_test_labels)    

y_pred_test_proba = model_tuned.predict_proba(X_test_scaled)

# Numeric metrics
acc = accuracy_score(Y_test, y_pred_test_labels)
auc_roc = roc_auc_score(Y_test, 
                         y_pred_test_proba,
                        multi_class='ovr', 
                        average='macro')

print(f"Test Accuracy: {acc:.4f}")
print(f"Test AUC-ROC: {auc_roc:.4f}")
####### TRAIN #######

Classification Report:
              precision    recall  f1-score   support

           0       0.91      0.88      0.89      8259
           1       0.90      0.95      0.93     15499
           2       0.93      0.87      0.90      8269

    accuracy                           0.91     32027
   macro avg       0.92      0.90      0.91     32027
weighted avg       0.91      0.91      0.91     32027

No description has been provided for this image
####### TEST #######

Classification Report:
              precision    recall  f1-score   support

           0       0.91      0.87      0.89      2065
           1       0.89      0.95      0.92      3875
           2       0.93      0.86      0.90      2067

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

No description has been provided for this image
Test Accuracy: 0.9065
Test AUC-ROC: 0.9435

After performing the hyperparameter tuning and then re-running the SVM model the SVM performs very strongly, achieving a high test accuracy of 90.65%, outperforming the Decision Tree model. Precision, recall, and F1-scores are consistently strong across all three engagement levels, ranging from 0.87 to 0.93, showing reliable and balanced performance. The confusion matrix indicates excellent class separation: the model correctly identifies the majority of Medium-engagement players (3683), with relatively few misclassifications. High and Low engagement levels are also classified accurately, with only minor confusion between Medium and High. The AUC-ROC score of 0.9434 further confirms excellent class separability. Overall, the SVM shows superior predictive ability compared to the Decision Tree, with higher accuracy, stronger recall for High-engagement players, and fewer misclassifications across the board.

InĀ [92]:
# Defining Decisions Tree with no hyperparameter tuning
dt_initial = DecisionTreeClassifier()
dt_initial.fit(X_train, Y_train)

# Train decision tree using train data
y_train_pred_dt = dt_initial.predict(X_train)
y_train_proba_dt = dt_initial.predict_proba(X_train)


# Test decision tree using test data
y_test_pred_dt = dt_initial.predict(X_test)
y_test_proba_dt = dt_initial.predict_proba(X_test)

print("######## DECISION TREE — TEST ########")
metrics_score(Y_test, y_test_pred_dt)

# Numeric metrics
dt_initial_acc = accuracy_score(Y_test, y_test_pred_dt)
dt_initial_auc = roc_auc_score(Y_test, y_test_proba_dt, multi_class='ovr', average='macro')

print(f"Decision Tree - Test Accuracy: {dt_initial_acc:.4f}")
print(f"Decision Tree - Test AUC-ROC: {dt_initial_auc:.4f}")
######## DECISION TREE — TEST ########

Classification Report:
              precision    recall  f1-score   support

           0       0.82      0.83      0.83      2065
           1       0.89      0.89      0.89      3875
           2       0.83      0.83      0.83      2067

    accuracy                           0.86      8007
   macro avg       0.85      0.85      0.85      8007
weighted avg       0.86      0.86      0.86      8007

No description has been provided for this image
Decision Tree - Test Accuracy: 0.8571
Decision Tree - Test AUC-ROC: 0.8869

The Decision Tree achieves a strong overall accuracy of 85.75%, with consistent precision, recall, and F1-scores across all three engagement classes (Low, Medium, High), each averaging around 0.83–0.89. The confusion matrix shows that the model predicts Medium engagement most accurately, correctly classifying the majority of Medium players (3441). Misclassifications are relatively low and balanced across classes, with most errors occurring between neighbouring categories—for example, some High players being predicted as Medium, and some Low players also being shifted into Medium. The AUC-ROC score of 0.8871 indicates excellent overall separability between the engagement levels. Overall, the model performs well, showing strong and consistent classification capability with only modest confusion between adjacent engagement levels.

InĀ [93]:
# Define parameter grid for tuning
param_grid_dt = {
    "max_depth": [3, 5, 7, 9, None],
    "min_samples_split": [2, 5, 10],
    "min_samples_leaf": [1, 2, 4],
    "criterion": ["gini", "entropy"]
}

dt = DecisionTreeClassifier()

grid_dt = GridSearchCV(
    estimator=dt,
    param_grid=param_grid_dt,
    cv=5,
    scoring="accuracy",
    n_jobs=-1,
    verbose=1
)

grid_dt.fit(X_train, Y_train)

print("Best parameters for Decision Tree:")
print(grid_dt.best_params_)
Fitting 5 folds for each of 90 candidates, totalling 450 fits
Best parameters for Decision Tree:
{'criterion': 'gini', 'max_depth': 9, 'min_samples_leaf': 4, 'min_samples_split': 5}

The grid search found the best Decision Tree configuration to be criterion = ā€˜gini’, max_depth = 9, min_samples_leaf = 4, and min_samples_split = 5. These parameters help control tree complexity, reducing overfitting while improving accuracy compared to the default settings.

InĀ [94]:
# Defining Decisions Tree with best parameter from grid search
dt = DecisionTreeClassifier(**grid_dt.best_params_)
dt.fit(X_train, Y_train)

# Train decision tree using train data
y_train_pred_dt = dt.predict(X_train)
y_train_proba_dt = dt.predict_proba(X_train)

# Print training metrics
print("######## TUNED DECISION TREE — TRAIN ########")
metrics_score(Y_train, y_train_pred_dt)


# Test decision tree using test data
y_test_pred_dt = dt.predict(X_test)
y_test_proba_dt = dt.predict_proba(X_test)

print("######## TUNED DECISION TREE — TEST ########")
metrics_score(Y_test, y_test_pred_dt)

# Numeric metrics
dt_acc = accuracy_score(Y_test, y_test_pred_dt)
dt_auc = roc_auc_score(Y_test, y_test_proba_dt, multi_class='ovr', average='macro')

print(f"Tuned Decision Tree - Test Accuracy: {dt_acc:.4f}")
print(f"Tuned Decision Tree - Test AUC-ROC: {dt_auc:.4f}")
######## TUNED DECISION TREE — TRAIN ########

Classification Report:
              precision    recall  f1-score   support

           0       0.93      0.90      0.91      8259
           1       0.93      0.96      0.95     15499
           2       0.93      0.90      0.92      8269

    accuracy                           0.93     32027
   macro avg       0.93      0.92      0.93     32027
weighted avg       0.93      0.93      0.93     32027

No description has been provided for this image
######## TUNED DECISION TREE — TEST ########

Classification Report:
              precision    recall  f1-score   support

           0       0.92      0.89      0.91      2065
           1       0.92      0.96      0.94      3875
           2       0.93      0.90      0.91      2067

    accuracy                           0.93      8007
   macro avg       0.93      0.92      0.92      8007
weighted avg       0.93      0.93      0.92      8007

No description has been provided for this image
Tuned Decision Tree - Test Accuracy: 0.9251
Tuned Decision Tree - Test AUC-ROC: 0.9414
InĀ [95]:
# Scale data
sc = StandardScaler()
model_data_scaled = sc.fit_transform(model_data.drop('EngagementLevel', axis=1))

# Normalise Data for DBSCAN Algorithm
model_data_normalized = normalize(model_data_scaled)
InĀ [96]:
# Reduce dimensionality with PCA
pca = PCA(n_components=2)
model_data_principal = pca.fit_transform(model_data_normalized)
model_data_principal = pd.DataFrame(model_data_principal, columns=['PC1', 'PC2'])
model_data_principal.head()
Out[96]:
PC1 PC2
0 -0.074372 -0.041889
1 0.000629 0.343343
2 0.573999 0.299799
3 -0.049641 0.378091
4 -0.268478 -0.414315

PCA simplifies the dataset into its most informative principal components as defined, making DBSCAN more effective at discovering natural player groups and reducing the chance of noisy or fragmented clusters.

InĀ [97]:
# Numpy array of all the cluster labels assigned to each data point
db_default = DBSCAN(eps = 0.0375, min_samples = 10).fit(model_data_principal)
labels = db_default.labels_
InĀ [98]:
# Building the label to colour mapping
colours = {}
colours[0] = 'r'
colours[1] = 'g'
colours[2] = 'b'
colours[3] = 'y'
colours[-1] = 'k'

# Building the colour vector for each data point
cvec = [colours[label] for label in labels]

# For the construction of the legend of the plot
r = plt.scatter(model_data_principal['PC1'], model_data_principal['PC2'], color ='r');
g = plt.scatter(model_data_principal['PC1'], model_data_principal['PC2'], color ='g');
b = plt.scatter(model_data_principal['PC1'], model_data_principal['PC2'], color ='b');
y = plt.scatter(model_data_principal['PC1'], model_data_principal['PC2'], color ='y');
k = plt.scatter(model_data_principal['PC1'], model_data_principal['PC2'], color ='k');

# Plotting P1 on the X-Axis and P2 on the Y-Axis 
# according to the colour vector defined
plt.figure(figsize =(9, 9))
plt.scatter(model_data_principal['PC1'], model_data_principal['PC2'], c = cvec)

# Building the legend
plt.legend((r, g, b, y, k), ('Label 0', 'Label 1', 'Label 2', 'Label 3', 'Label -1'))

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

The PCA plot shows the data forming three clear groups, and DBSCAN successfully identifies these as three main clusters, with a few outliers marked in black. This confirms strong natural structure in the data and effective clustering.

InĀ [99]:
# Analysing model using silhouette score
silhouette_avg = silhouette_score(model_data_principal, labels)
print(f'Silhouette Score: {silhouette_avg:.4f}')
Silhouette Score: 0.3274

A silhouette score of 0.3274 shows that the DBSCAN clusters are moderately defined and that the data does form meaningful groups, but the separation between clusters is not very strong.