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.
# 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')
# 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()
# Load dataset
data = pd.read_csv('online_gaming_behavior_dataset.csv')
# Display first few rows of the dataset
data.head(25)
| 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 |
# 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.
# Check for unique values within each column
data.nunique()
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
# Summary statistics of the dataset
data.describe().round(2)
| 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.
# 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']
# Summary statistics for numerical columns
data[num_cols].describe().T
| 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.
# Create Histograms for the numerical columns
data[num_cols].hist(bins=15, figsize=(15, 10), layout=(4, 4))
plt.show()
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.
# 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.
# 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.
# Mean of numerical variables grouped by engagment level
data.groupby(['EngagementLevel'])[num_cols].mean().round(0)
| 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.
# 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()
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.
# 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)
| 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.
# 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()
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.
# Engagmenet Level by Playtime Hours
playtime_engagement = data.pivot_table(index='EngagementLevel', values='PlayTimeHours', aggfunc=('mean','std'),observed=True)
playtime_engagement.round(2)
| 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.
# 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)
| mean | std | |
|---|---|---|
| EngagementLevel | ||
| High | 131.92 | 34.07 |
| Low | 66.88 | 48.59 |
| Medium | 89.86 | 43.72 |
# 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)
| 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.
# 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()
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.
# 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()
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.
# Engagement Level Per Location
region_engagement = data.pivot_table(index='EngagementLevel', columns='Location', values='PlayerID', aggfunc='count', observed=True)
region_engagement.round(0)
| 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.
# Engagement level by Achievements Unlocked
achievements_engagement = data.pivot_table(index='EngagementLevel', values='AchievementsUnlocked', aggfunc=('mean','std'),observed=True)
achievements_engagement.round(2)
| mean | std | |
|---|---|---|
| EngagementLevel | ||
| High | 25.10 | 14.42 |
| Low | 22.66 | 14.19 |
| Medium | 25.22 | 14.48 |
# 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()
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
# 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.
# Seperate target variable and features
Y = model_data.EngagementLevel
X = model_data.drop('EngagementLevel', axis=1)
# 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)
# 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)
# 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
####### 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
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
####### 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
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
####### 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
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
####### 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
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.
# 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'}
# 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
####### 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
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.
# 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
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.
# 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.
# 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
######## 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
Tuned Decision Tree - Test Accuracy: 0.9251 Tuned Decision Tree - Test AUC-ROC: 0.9414
# 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)
# 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()
| 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.
# 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_
# 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()
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.
# 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.