IMPORTING ESSENTIAL LIBRARIES¶

In [1]:
# Data manipulation and analysis
import pandas as pd           
import numpy as np            

# Visualisation libraries
import matplotlib.pyplot as plt   
import seaborn as sns             

# Splitting data & hyperparameter tuning
from sklearn.model_selection import (
    train_test_split,   
    GridSearchCV,       
    RandomizedSearchCV, 
    cross_val_score     
)

# Data preprocessing
from sklearn.preprocessing import StandardScaler, OneHotEncoder  
from sklearn.compose import ColumnTransformer                    
from sklearn.pipeline import Pipeline                            
from sklearn.impute import SimpleImputer                         

# Model evaluation metrics
from sklearn.metrics import (
    accuracy_score,      
    precision_score,     
    recall_score,        
    f1_score,            
    confusion_matrix,    
    classification_report,
    roc_auc_score,       
    roc_curve,           
    auc                  
)

# ALgorithms to use
from sklearn.tree import DecisionTreeClassifier, plot_tree   
from sklearn.svm import SVC                                  
from sklearn.cluster import KMeans                           
from sklearn.metrics import silhouette_score                 
from sklearn.decomposition import PCA                        

import joblib
import time
import warnings
warnings.filterwarnings('ignore')
sns.set(style='whitegrid')

LOADING THE DATA + QUICK EDA¶

In [3]:
df = pd.read_csv(r"C:\Users\S2117368\OneDrive - University of Wales Trinity Saint David\Year 3\AI\online_gaming_behavior_dataset.csv")
In [29]:
df.head() # load of the first 5 rows of the dataset  
Out[29]:
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 [31]:
df.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

Observations

  • There are 40034 observations and 13 columns in the dataset
  • All the columns have 40034 non-null values, that means there are no missing values in the data
In [33]:
# checking unique values 
df.nunique()
Out[33]:
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

Observations

  • PlayerID is a unique identifier will be dropped from modeling.
  • PlayTimeHours also has unique values for each player, but it is meaningful as a continuous metric.
  • Columns like Gender, Location, GameGenre, InGamePurchases, GameDifficulty are categorical the will be encoded for modeling.
  • Columns like Age, SessionsPerWeek, AvgSessionDurationMinutes, PlayerLevel, AchievementsUnlocked are numerical and will be scaled if needed.
  • EngagementLevel has 3 unique classes, this is a multiclass classification problem.
  • Binary low-cardinality features like InGamePurchases, 2 unique values will be treated as categorical or numerical.
In [5]:
# dropping the columns 
df = df.drop(['PlayerID'], axis=1)
In [7]:
# creating numerical columns
num_cols = ['Age', 'PlayTimeHours', 'SessionsPerWeek', 'AvgSessionDurationMinutes', 
            'PlayerLevel', 'AchievementsUnlocked']

# creating categorical columns
cat_cols = ['Gender', 'Location', 'GameGenre', 'InGamePurchases', 'GameDifficulty']

Checking each numeric variable separately¶

In [9]:
#  Checking summary statistics
df[num_cols].describe().T
Out[9]:
count mean std min 25% 50% 75% max
Age 40034.0 31.992531 10.043227 15.000000 23.000000 32.000000 41.000000 49.000000
PlayTimeHours 40034.0 12.024365 6.914638 0.000115 6.067501 12.008002 17.963831 23.999592
SessionsPerWeek 40034.0 9.471774 5.763667 0.000000 4.000000 9.000000 14.000000 19.000000
AvgSessionDurationMinutes 40034.0 94.792252 49.011375 10.000000 52.000000 95.000000 137.000000 179.000000
PlayerLevel 40034.0 49.655568 28.588379 1.000000 25.000000 49.000000 74.000000 99.000000
AchievementsUnlocked 40034.0 24.526477 14.430726 0.000000 12.000000 25.000000 37.000000 49.000000

Observations

  • The average player is about 32 years old, and most are between 23 and 41. The youngest is 15 and the oldest is 49, so most players are fairly young.
  • Players spend around 12 hours in total, though this is probably total playtime, not daily. Some play very little, while others play a lot more.
  • Most players play 9 to 10 times a week, but some play almost every day and some play less often.
  • Each session lasts about 95 minutes on average, with most sessions between 52 and 137 minutes.
  • Player levels are around 50 on average, ranging from beginners at level 1 to highly advanced players at leve 99.
  • Players unlock about 25 achievements on average. Some have none, while the most dedicated players reach upto 50.

Now let’s take a closer look at how these variables are spread out

In [11]:
df["EngagementLevel"].value_counts(normalize=True) * 100
Out[11]:
EngagementLevel
Medium    48.393865
High      25.818055
Low       25.788080
Name: proportion, dtype: float64
In [11]:
# Creating histograms 
df [num_cols].hist(figsize=(14, 14))
plt.show()
No description has been provided for this image

Observations

  • Age is spread fairly evenly from 15 to 49 meaning the game attracts a wide range of players
  • PlayTimeHours looks evenly distributed, so players spend very different amounts of total time in the game without any clear pattern.
  • SessionsPerWeek also varies widely, showing that some players play very little while others play a lot.
  • AvgSessionDurationMinutes is spread across short to long sessions, suggesting players have mixed session habits.
  • PlayerLevel ranges smoothly from beginner to high-level players with no strong concentration in one area.
  • AchievementsUnlocked is also evenly spread, meaning players earn achievements at many different rates.

Checking each categorical variable separately¶

In [13]:
for col in cat_cols:
     print(df[col].value_counts(normalize=True))
     print('*' * 40)
Gender
Male      0.598466
Female    0.401534
Name: proportion, dtype: float64
****************************************
Location
USA       0.399660
Europe    0.299845
Asia      0.202203
Other     0.098291
Name: proportion, dtype: float64
****************************************
GameGenre
Sports        0.201029
Action        0.200804
Strategy      0.200130
Simulation    0.199406
RPG           0.198631
Name: proportion, dtype: float64
****************************************
InGamePurchases
0    0.799146
1    0.200854
Name: proportion, dtype: float64
****************************************
GameDifficulty
Easy      0.49995
Medium    0.30002
Hard      0.20003
Name: proportion, dtype: float64
****************************************

Observatiosn¶

  • Gender around 60% male and 40% female, fairly balanced.
  • Location mostly USA around 40% and Europe 30% with some players from Asia and other regions.
  • GameGenre All genres are evenly distributed 20% each, showing no strong preference.
  • InGamePurchases about 80% make no purchases, low microtransaction activity.
  • GameDifficulty most players choose easy, around 50%, then medium 30% and hard 20%

Bivariate analysis for categorical variables¶

Next, we will look at how InGamePurchases is related to other categorical variables. By using crosstabs to check row percentages and create stacked bar charts to visualise the patterns. This will help show how different factors influence InGamePurchases..

In [10]:
for col in cat_cols:
    if col != 'InGamePurchases':  
        (pd.crosstab(df[col], df['InGamePurchases'], normalize='index') * 100).plot(
            kind='bar', figsize=(8,4), stacked=True
        )
        plt.ylabel('Percentage of Players')
        plt.title(f'{col} vs In-Game Purchases')
        plt.show()
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image

Observations¶

  • Across all categories the percentage of players making purchases stays close to 20%, showing that buying behavior doesn’t change much based on gender, location, genre or difficulty.
  • Location shows only small differences players in the USA make slightly more purchases than those in Asia or Other, but the gap is small.
  • GameGenre differences are minimal purchase rates stay close to 20% for all genres, meaning players behave similarly regardless of what type of game they play.
  • GameDifficulty also remains consistent, with Easy, Medium and Hard modes all showing roughly the same purchase rate.
In [17]:
# Checking how numeric behaviour differs between players who purchase in‑game items and those who don’t
df.groupby('InGamePurchases')[num_cols].mean()
Out[17]:
Age PlayTimeHours SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked
InGamePurchases
0 31.993467 12.045395 9.456944 94.867408 49.562060 24.525771
1 31.988807 11.940694 9.530780 94.493222 50.027609 24.529287

Observations¶

Players who make in‑game purchases show only very small differences compared to those who don’t. Their average age, playtime, sessions per week, and session duration differ by less than 1%, meaning both groups behave almost the same in terms of raw activity. Purchasing players have about 1% higher player levels, suggesting they advance slightly faster. They also unlock around 1% more achievements, indicating they may be a little more engaged. Overall, the differences are very small spending money does not strongly change how players behave, but purchasers appear slightly more invested in the game.

Checking correlations between numerical features to understand their relationships and spot any strong positive or negative patterns before modeling. A correlation matrix and heatmap will be created to visualise these patterns.

In [19]:
plt.figure(figsize=(15,8))
sns.heatmap(df[num_cols].corr(), annot=True, fmt='.2f', cmap='YlOrRd')
plt.show()
No description has been provided for this image

Observations:¶

  • The numerical features in this dataset show almost no correlation with each other, as all values are close to zero. This means things like age, playtime, session frequency and player level do not strongly relate.
  • The variables seem independent and no strong patterns appear in the data.

Model Building¶

Now that the data has been fully explored and cleaned, we can move on to building predictive models. The goal is to predict EngagementLevel using the prepared dataset

Approach:¶

  • Data Preparation
  • Train Test Split
  • Build Baseline Models
  • Hyperparameter Tuning
  • Evaluate on Test Data
  • Select the Best Model

Data Preparation¶

In [12]:
# Creating dummy variables for categorical columns
df = pd.get_dummies(df, columns=cat_cols, drop_first=True)
In [14]:
# Separating independent (X) and dependent (y) variables
X = df.drop('EngagementLevel', axis=1)
y = df['EngagementLevel']

# Quick check
print(X.shape, y.shape)
print(y.value_counts())
(40034, 18) (40034,)
EngagementLevel
Medium    19374
High      10336
Low       10324
Name: count, dtype: int64

Splitting the data into 80% train and 20% test, using stratification to preserve class balance in both sets

80/20 train-test split will be used because it provides a good balance between having enough data to train the model effectively while still keeping a reliable portion for testing. Since the dataset is reasonably sised, this ratio helps the model learn better patterns without reducing test accuracy.

In [25]:
# Train/test split
X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,      # 20% test, 80% train
    random_state=1,     # ensures reproducible results
    stratify=y          # keeps class distribution same in train/test
)

# Quick check
print(X_train.shape, X_test.shape)
print(y_train.value_counts(normalize=True))
print(y_test.value_counts(normalize=True))
(32027, 18) (8007, 18)
EngagementLevel
Medium    0.483935
High      0.258188
Low       0.257876
Name: proportion, dtype: float64
EngagementLevel
Medium    0.483952
High      0.258149
Low       0.257899
Name: proportion, dtype: float64

Scaling¶

  • We will use StandardScaler, which standardises features by removing the mean and scaling to unit variance.
  • Although Decision Trees do not require scaling, using scaled features does not negatively affect tree‑based models, so a single scaled dataset will be used for consistency.
  • Scaling will be performed after the train–test split to prevent data leakage.
In [27]:
# Scaling the data using StandardScaler for SVM
sc = StandardScaler()

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

# Transform test data
X_test_scaled = sc.transform(X_test)
X_test_scaled = pd.DataFrame(X_test_scaled, columns=X.columns)
In [29]:
# Function to print evaluation metrics and confusion matrix
# This will help evaluate how well the model predicts in-game purchases
def metrics_score(actual, predicted):
    print(classification_report(actual, predicted))

    cm = confusion_matrix(actual, predicted)
    plt.figure(figsize=(8,5))
    sns.heatmap(cm, annot=True, fmt='.2f', 
                xticklabels=['No Purchase', 'Purchase'],
                yticklabels=['No Purchase', 'Purchase'])
    plt.ylabel('Actual')
    plt.xlabel('Predicted')
    plt.show()

Model Building¶

Support Vector Machine¶

Linear SVM (step 1): train and evaluate on TRAIN set

  • we will use a linear kernel SVM to get a simple linear baseline.
  • We will train on X_train_scaled (scaled features) and y_train.
  • We then predict on X_train_scaled and print metrics to inspect training behaviour.
In [31]:
# Creating the linear SVM model
svm_linear = SVC(kernel='linear', probability=True, class_weight='balanced', random_state=42)

# Fitting on scaled training data (X_train_scaled, y_train must exist)
svm_linear.fit(X_train_scaled, y_train)

# Predict on training set 
y_pred_train_svm = svm_linear.predict(X_train_scaled)

# Evaluate on training set
metrics_score(y_train, y_pred_train_svm)
              precision    recall  f1-score   support

        High       0.81      0.91      0.86      8269
         Low       0.69      0.80      0.74      8259
      Medium       0.86      0.74      0.79     15499

    accuracy                           0.80     32027
   macro avg       0.79      0.81      0.80     32027
weighted avg       0.80      0.80      0.80     32027

No description has been provided for this image
In [32]:
# Predicting on the test set using the trained SVM model
y_pred_test_svm = svm_linear.predict(X_test_scaled)

# Evaluating performance on the test set
metrics_score(y_test, y_pred_test_svm)
              precision    recall  f1-score   support

        High       0.81      0.90      0.85      2067
         Low       0.70      0.81      0.75      2065
      Medium       0.86      0.74      0.80      3875

    accuracy                           0.80      8007
   macro avg       0.79      0.82      0.80      8007
weighted avg       0.81      0.80      0.80      8007

No description has been provided for this image

Observations:

  • The SVM model achieves 80% accuracy on both the training set (0.80) and the test set (0.80).This consistency indicates that the model is not overfitting and generalises well to unseen data.
  • The recall for some classes however, is lower around 74% on average,suggesting that the model may miss certain cases and might not correctly identify all instances of those classes.

RBF Kernel¶

Now that we have trained and evaluated the Linear SVM, the next step is to train an SVM with an RBF kernel (Radial Basis Function).

In [71]:
from sklearn.preprocessing import label_binarize
from sklearn.metrics import precision_recall_curve
import matplotlib.pyplot as plt

# Binarize labels for all three classes
y_train_bin = label_binarize(y_train, classes=['High', 'Medium', 'Low'])

# Column 0 -> High, Column 1 -> Medium, Column 2 -> Low
class_names = ['High', 'Medium', 'Low']
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
for i, class_name in enumerate(class_names):
    precisions, recalls, thresholds = precision_recall_curve(y_train_bin[:, i], y_scores_rbf[:, i])
    ax = axes[i]
    ax.plot(thresholds, precisions[:-1], 'b--', label='Precision')
    ax.plot(thresholds, recalls[:-1], 'g-', label='Recall')
    ax.set_xlabel('Threshold')
    ax.set_ylabel('Score')
    ax.set_title(f'Precision & Recall vs Threshold - {class_name}')
    ax.set_ylim([0, 1])
    ax.legend(loc='upper left')

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

Observations

  • The plots show how precision and recall change for each class (High, Medium, Low) at different classification thresholds.
  • The dashed lines represent precision, and the solid lines represent recall.
  • These curves help identify a threshold that balances precision and recall for each class, which can be useful if some classes (like Low) are more important to detect correctly.
  • Since this is a multi-class problem, each class is plotted separately (one-vs-rest), making it easier to interpret the model’s behavior for each category.

Hyperparameter tuning for RBF SVM (GridSearchCV)¶

In [39]:
# Hyperparameter grid
param_grid = {
    'C': [0.1, 1, 10],        
    'gamma': ['scale', 0.01, 0.1, 1],  
    'kernel': ['rbf'],
    'class_weight': [None, 'balanced']
}

# base estimator
svm_rbf = SVC(probability=True, random_state=42)

# GridSearchCV setup
gs = GridSearchCV(
    estimator=svm_rbf,
    param_grid=param_grid,
    scoring='f1_weighted',   
    cv=3,                    
    n_jobs=-1,
    verbose=2
)

# Fit grid on scaled training data
gs.fit(X_train_scaled, y_train)

import os
os.makedirs('models', exist_ok=True)

# Save best model
joblib.dump(gs.best_estimator_, 'models/svm_rbf_grid_best.joblib')

# Show best params and score
print("Best params:", gs.best_params_)
print("Best CV f1_weighted:", gs.best_score_)

# Evaluate on test data
best_svm = gs.best_estimator_
y_test_pred = best_svm.predict(X_test_scaled)

metrics_score(y_test, y_test_pred)
Best params: {'C': 10, 'class_weight': None, 'gamma': 0.01, 'kernel': 'rbf'}
Best CV f1_weighted: 0.8861278256663674
              precision    recall  f1-score   support

        High       0.92      0.85      0.88      2067
         Low       0.89      0.84      0.86      2065
      Medium       0.87      0.93      0.90      3875

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

No description has been provided for this image

Tuned SVM (RBF Kernel)

  • The tuned RBF SVM achieved a strong cross-validated weighted F1-score of 0.886, indicating excellent generalisation performance.
  • The test accuracy improved to 0.89, which is significantly higher than the baseline linear SVM (0.80).
  • High and Low classes showed good performance with 92% precision and 85% recall for High, and 89% precision and 84% recall for Low.
  • The Medium class achieved the highest recall (0.93), meaning the model is particularly good at identifying Medium-engagement players.
  • The overall weighted F1-score of 0.89 suggests balanced performance across all classes.
  • Conclusion, The tuned RBF SVM outperforms the linear SVM, successfully capturing non-linear relationships in player behavior and providing the best model for predicting engagement levels in this dataset.

Decision Tree¶

  • First of all, we will train a baseline Decision Tree
  • Uses X_train and y_train (unscaled)
  • Decision trees don't require feature scaling
  • We set random_state for reproducibility
In [49]:
# Create baseline Decision Tree
dt = DecisionTreeClassifier(random_state=42)

# Fit on training data
dt.fit(X_train, y_train)

print("Baseline Decision Tree trained.")
Baseline Decision Tree trained.

Evaluating baseline Decision Tree on train and test¶

  • we will use metrics_score() helper, (prints classification_report + confusion matrix)
  • Also print simple accuracy numbers for quick comparison
In [54]:
# Predict on train
y_pred_train_dt = dt.predict(X_train)
print("=== TRAIN SET PERFORMANCE ===")
metrics_score(y_train, y_pred_train_dt)

# Quick train accuracy
train_acc_dt = (y_pred_train_dt == y_train).mean()
print(f"Train accuracy (Decision Tree baseline): {train_acc_dt:.4f}\n")

# Predict on test
y_pred_test_dt = dt.predict(X_test)
print("=== TEST SET PERFORMANCE ===")
metrics_score(y_test, y_pred_test_dt)

# Quick test accuracy
test_acc_dt = (y_pred_test_dt == y_test).mean()
print(f"Test accuracy (Decision Tree baseline): {test_acc_dt:.4f}")
=== TRAIN SET PERFORMANCE ===
              precision    recall  f1-score   support

        High       1.00      1.00      1.00      8269
         Low       1.00      1.00      1.00      8259
      Medium       1.00      1.00      1.00     15499

    accuracy                           1.00     32027
   macro avg       1.00      1.00      1.00     32027
weighted avg       1.00      1.00      1.00     32027

No description has been provided for this image
Train accuracy (Decision Tree baseline): 1.0000

=== TEST SET PERFORMANCE ===
              precision    recall  f1-score   support

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

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

No description has been provided for this image
Test accuracy (Decision Tree baseline): 0.8355

Observations on Decision Tree Baseline Model¶

  • Training Performance: achieved 100% accuracy on the training set, with precision, recall and F1 of 1.00 for all classes.This suggests overfitting the model memorised the training data instead of learning general patterns.
  • Test Performance: test accuracy dropped to 83.55%, indicating the model struggles to generalise.
  • Class-level F1: High: 0.82 Low: 0.80 Medium: 0.86 (best-performing class). This means the model performs decently on the test set but much worse than on training data, confirming overfitting.
  • Conclusion: The baseline Decision Tree is overfitted on the training data and needs hyperparameter tuning to improve generalisation and performance on unseen data.

Turning Decision Tree¶

In [57]:
# We will use smaller grid because full grid is too slow
param_grid_small = {
    'criterion': ['gini', 'entropy'],
    'max_depth': [5, 10, None],
    'min_samples_split': [2, 5]
}

grid_dt_small = GridSearchCV(
    DecisionTreeClassifier(random_state=42),
    param_grid_small,
    scoring='f1_weighted',
    cv=3,
    n_jobs=1,
    verbose=2
)
grid_dt_small.fit(X_train, y_train)
best_dt = grid_dt_small.best_estimator_
joblib.dump(best_dt, 'models/decision_tree_grid_best_small.joblib')
print("Best params (small grid):", grid_dt_small.best_params_)
print("Best CV f1_weighted (small grid):", grid_dt_small.best_score_)
Fitting 3 folds for each of 12 candidates, totalling 36 fits
[CV] END ...criterion=gini, max_depth=5, min_samples_split=2; total time=   0.0s
[CV] END ...criterion=gini, max_depth=5, min_samples_split=2; total time=   0.0s
[CV] END ...criterion=gini, max_depth=5, min_samples_split=2; total time=   0.0s
[CV] END ...criterion=gini, max_depth=5, min_samples_split=5; total time=   0.0s
[CV] END ...criterion=gini, max_depth=5, min_samples_split=5; total time=   0.0s
[CV] END ...criterion=gini, max_depth=5, min_samples_split=5; total time=   0.0s
[CV] END ..criterion=gini, max_depth=10, min_samples_split=2; total time=   0.0s
[CV] END ..criterion=gini, max_depth=10, min_samples_split=2; total time=   0.0s
[CV] END ..criterion=gini, max_depth=10, min_samples_split=2; total time=   0.0s
[CV] END ..criterion=gini, max_depth=10, min_samples_split=5; total time=   0.0s
[CV] END ..criterion=gini, max_depth=10, min_samples_split=5; total time=   0.0s
[CV] END ..criterion=gini, max_depth=10, min_samples_split=5; total time=   0.0s
[CV] END criterion=gini, max_depth=None, min_samples_split=2; total time=   0.1s
[CV] END criterion=gini, max_depth=None, min_samples_split=2; total time=   0.1s
[CV] END criterion=gini, max_depth=None, min_samples_split=2; total time=   0.1s
[CV] END criterion=gini, max_depth=None, min_samples_split=5; total time=   0.1s
[CV] END criterion=gini, max_depth=None, min_samples_split=5; total time=   0.1s
[CV] END criterion=gini, max_depth=None, min_samples_split=5; total time=   0.1s
[CV] END criterion=entropy, max_depth=5, min_samples_split=2; total time=   0.0s
[CV] END criterion=entropy, max_depth=5, min_samples_split=2; total time=   0.0s
[CV] END criterion=entropy, max_depth=5, min_samples_split=2; total time=   0.0s
[CV] END criterion=entropy, max_depth=5, min_samples_split=5; total time=   0.0s
[CV] END criterion=entropy, max_depth=5, min_samples_split=5; total time=   0.0s
[CV] END criterion=entropy, max_depth=5, min_samples_split=5; total time=   0.0s
[CV] END criterion=entropy, max_depth=10, min_samples_split=2; total time=   0.0s
[CV] END criterion=entropy, max_depth=10, min_samples_split=2; total time=   0.0s
[CV] END criterion=entropy, max_depth=10, min_samples_split=2; total time=   0.0s
[CV] END criterion=entropy, max_depth=10, min_samples_split=5; total time=   0.0s
[CV] END criterion=entropy, max_depth=10, min_samples_split=5; total time=   0.0s
[CV] END criterion=entropy, max_depth=10, min_samples_split=5; total time=   0.0s
[CV] END criterion=entropy, max_depth=None, min_samples_split=2; total time=   0.1s
[CV] END criterion=entropy, max_depth=None, min_samples_split=2; total time=   0.1s
[CV] END criterion=entropy, max_depth=None, min_samples_split=2; total time=   0.1s
[CV] END criterion=entropy, max_depth=None, min_samples_split=5; total time=   0.1s
[CV] END criterion=entropy, max_depth=None, min_samples_split=5; total time=   0.1s
[CV] END criterion=entropy, max_depth=None, min_samples_split=5; total time=   0.1s
Best params (small grid): {'criterion': 'entropy', 'max_depth': 10, 'min_samples_split': 5}
Best CV f1_weighted (small grid): 0.8929661417063633
In [59]:
# Evaluating tuned Decision Tree on test
y_pred_test_best_dt = best_dt.predict(X_test)
print("=== TUNED DECISION TREE: TEST PERFORMANCE ===")
metrics_score(y_test, y_pred_test_best_dt)
print("Test accuracy (tuned DT):", (y_pred_test_best_dt == y_test).mean())
=== TUNED DECISION TREE: TEST PERFORMANCE ===
              precision    recall  f1-score   support

        High       0.91      0.87      0.89      2067
         Low       0.89      0.86      0.87      2065
      Medium       0.90      0.93      0.91      3875

    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 (tuned DT): 0.8979642812539028

Tuned Decision Tree Performance: key observations

  • The tuned Decision Tree achieved approximately 0.90 accuracy, a significant improvement over the baseline (0.84).
  • Precision, recall, and F1‑scores are all balanced across High, Medium, and Low engagement classes.
  • The model generalises well and no longer overfits (unlike the baseline tree which had 100% train accuracy).
  • Performance for the “Medium” class is particularly strong (F1 = 0.91), which is important because it is the largest class.
  • Hyperparameter tuning reduced depth and controlled splits, resulting in a more stable and accurate model.

Comparing Decision Tree vs SVM¶

In [64]:
# Predictions
y_pred_svm = best_svm.predict(X_test_scaled)   
y_pred_dt = best_dt.predict(X_test)            

# Summary function to extract key metrics per model
def summary_metrics(y_true, y_pred):
    cr = classification_report(y_true, y_pred, output_dict=True)
    acc = accuracy_score(y_true, y_pred)
    return {
        'accuracy': acc,
        'precision_weighted': cr['weighted avg']['precision'],
        'recall_weighted': cr['weighted avg']['recall'],
        'f1_weighted': cr['weighted avg']['f1-score']
    }

summary = pd.DataFrame([
    summary_metrics(y_test, y_pred_dt),
    summary_metrics(y_test, y_pred_svm)
], index=['Decision Tree (tuned)', 'SVM (tuned)'])

print(summary.round(3))
                       accuracy  precision_weighted  recall_weighted  \
Decision Tree (tuned)     0.898               0.898            0.898   
SVM (tuned)               0.886               0.888            0.886   

                       f1_weighted  
Decision Tree (tuned)        0.898  
SVM (tuned)                  0.886  

Decision Tree vs SVM (Tuned Models) Comparison Summary¶

The tuned Decision Tree outperformed the tuned SVM across all major metrics:

  • Accuracy: DT 0.898 > SVM 0.886
  • Precision (weighted): DT 0.898 > SVM 0.888
  • Recall (weighted): DT 0.898 > SVM 0.886
  • F1‑score (weighted): DT 0.898 > SVM 0.886
  • The Decision Tree gains an advantage in handling the non-linear structure and mixed feature types in this dataset.
  • SVM still performs strongly and consistently, but is slightly less effective at separating the three engagement classes.
  • Overall, the tuned Decision Tree is the best-performing model for this classification task.

K-Means¶

Visualising Age vs PlayTimeHours¶

In [28]:
# Basic inspection of the relationship between Age and PlayTimeHours
plt.figure(figsize=(10,6))
plt.scatter(df["Age"], df["PlayTimeHours"], alpha=0.7)
plt.xlabel("Age")
plt.ylabel("Weekly Play Time (hrs)")
plt.title("Distribution of Player Age vs Weekly Play Time")
plt.show()
No description has been provided for this image

Observation

  • The scatterplot shows that weekly play time remains high and relatively consistent across all age groups, with most players logging between 20 and 25 hours per week.
  • There is no clear trend or separation between younger and older players, suggesting that age alone is not a strong predictor of weekly play time.
  • This also explains why clustering on these two features alone may form overlapping groups with limited separation.

Clustering on Age & PlayTimeHours¶

In [30]:
# Run K-Means with 3 groups on the selected features
kmeans_ap = KMeans(n_clusters=3, random_state=42)
df["Cluster_AgePlay"] = kmeans_ap.fit_predict(df[["Age", "PlayTimeHours"]])

# Plot the resulting clusters
plt.figure(figsize=(10,6))
sns.scatterplot(
    x=df["Age"],
    y=df["PlayTimeHours"],
    hue=df["Cluster_AgePlay"],
    palette="Set1",
    alpha=0.6
)
plt.xlabel("Age")
plt.ylabel("Weekly Play Time (hrs)")
plt.title("K-Means Groups Based on Age and Play Time")
plt.show()
No description has been provided for this image

Observations

  • The clustering results show that players are grouped mainly based on weekly play time rather than age.
  • Although play time decreases slightly with older age groups, the separation between clusters is still weak because age does not vary meaningfully across the dataset.
  • As a result, K‑Means mainly divides players into high play-time, moderate play-time, and low play-time groups.
  • Age contributes very little to the separation, reinforcing that play behaviour is largely age‑independent in this datase.

SessionsPerWeek vs AchievementsUnlocked¶

In [38]:
# K-Means using gameplay frequency and achievements
kmeans_sa = KMeans(n_clusters=3, random_state=42)
df["Cluster_SessAch"] = kmeans_sa.fit_predict(df[["SessionsPerWeek", "AchievementsUnlocked"]])

plt.figure(figsize=(10,6))
sns.scatterplot(
    x=df["SessionsPerWeek"],
    y=df["AchievementsUnlocked"],
    hue=df["Cluster_SessAch"],
    palette="Set1",
    alpha=0.7
)
plt.xlabel("Sessions per Week")
plt.ylabel("Achievements Earned")
plt.title("Clusters Based on Sessions vs Achievements")
plt.show()
No description has been provided for this image

Observations

  • The clustering separates players mainly by activity level (sessions per week) and achievement progression.
  • One cluster represents high‑engagement players with many sessions and many achievements unlocked.
  • Another cluster includes moderate players with mid‑range sessions and achievements.
  • A third cluster contains low‑activity users who play less and unlock fewer achievements.
  • This feature pair provides stronger behavioural separation than Age + PlayTimeHours

Elbow Method (Age vs PlayTimeHours¶

In [41]:
wcss = []
K_range = range(1, 9)

for k in K_range:
    model = KMeans(n_clusters=k, init="k-means++", max_iter=300, n_init=10, random_state=42)
    model.fit(df[["Age", "PlayTimeHours"]])
    wcss.append(model.inertia_)

plt.figure(figsize=(10,6))
plt.plot(K_range, wcss, marker='o')
plt.xlabel("Number of Clusters (k)")
plt.ylabel("WCSS")
plt.title("Elbow Curve for Age vs PlayTimeHours")
plt.show()
No description has been provided for this image

Observation:

  • The line drops quickly from 1 to 3 clusters, then starts to level off.
  • After k = 3, adding more clusters doesn’t improve the result much.
  • So 3 clusters is the most sensible choice for this data.

Silhouette Scores¶

In [44]:
for k in range(2, 9):
    temp = KMeans(n_clusters=k, random_state=42, n_init=10)
    labels = temp.fit_predict(df[["Age", "PlayTimeHours"]])
    score = silhouette_score(df[["Age", "PlayTimeHours"]], labels)
    print(f"k = {k}, silhouette = {score:.3f}")
k = 2, silhouette = 0.431
k = 3, silhouette = 0.394
k = 4, silhouette = 0.383
k = 5, silhouette = 0.388
k = 6, silhouette = 0.382
k = 7, silhouette = 0.367
k = 8, silhouette = 0.382

Observation:

  • The silhouette score is highest at k = 2 (0.431), and slightly lower at k = 3 (0.394).
  • After k = 3, the silhouette values stay almost flat, showing no clear improvement.
  • This means the dataset doesn’t naturally form very strong clusters using only Age and PlayTimeHours.
  • Even though silhouette suggests k = 2 is slightly better, k = 3 is still acceptable, especially because it matches the elbow method and gives more meaningful behavioural groups
In [46]:
pd.crosstab(df["Cluster_SessAch"], df["EngagementLevel"])
Out[46]:
EngagementLevel High Low Medium
Cluster_SessAch
0 3124 3748 5880
1 3666 2971 6983
2 3546 3605 6511

Observation:¶

  • Cluster 0 contains many Low and Medium engagement players, showing this group represents lower‑activity behaviour.
  • Cluster 1 has the highest number of Medium and High engagement players, suggesting they are consistently active and achievement‑driven.
  • Cluster 2 includes a mix of High and Low players, but Medium is still strong meaning this group shows mixed behaviour, but still more active than Cluster 0.
  • Overall, the crosstab shows that clustering based on gameplay metrics reflects real engagement patterns, which supports the usefulness of the model. model.
In [ ]: