Case Study - Predict Online Gaming Behavior Dataset¶


About The Dataset¶

Author: Jonthan Wilcox 2206641

Date: November 15th, 2025

Description: This dataset contains information about how players interact in online games, including their demographics, game details, engagement levels, and whether or not they continue playing (player retention).

Objective¶

  • Apply supervised learning algorithms, specifically, Decision Tree and Support Vector Machine (SVM) to classify players into engagement categories: High, Medium, and Low.

  • Optimise model performance through data pre preprocessing and hyperparameter tuning, measuring improvements with accuracy, precision, recall, F1-score, and ROC-AUC.

  • Use unsupervised learning to explore underlying patterns in player demographics and gaming habits.

  • Compare and interpret the results from both supervised and unsupervised approaches.

Features:¶

Feature Description
PlayerID Unique identifier for each player.
Age Age of the player.
Gender Gender of the player.
Location Geographic location of the player.
GameGenre Genre of the game the player is engaged in.
PlayTimeHours Average hours spent playing per session.
InGamePurchases Indicates whether the player makes in-game purchases (0 = No, 1 = Yes).
GameDifficulty Difficulty level of the game.
SessionsPerWeek Number of gaming sessions per week.
AvgSessionDurationMinutes Average duration of each gaming session in minutes.
PlayerLevel Current level of the player in the game.
AchievementsUnlocked Number of achievements unlocked by the player.
EngagementLevel TARGET VARIABLE, Categorized engagement level reflecting player retention ('High', 'Medium', 'Low').

Target Variable:¶

EngagementLevel: Indicates the level of player engagement categorized as 'High', 'Medium', or 'Low'.


Import libraries¶

In [38]:
import pandas as pd  # For data manipulation
import numpy as np # For numerical calculations
import matplotlib.pyplot as plt # For data visualisations
import seaborn as sns # Advanced statistics

# To scale the data using z-score 
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split

# Algorithm packages
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.model_selection import GridSearchCV # automatically test different hyperparameters

# Model evaluation metrics
from sklearn import metrics
from sklearn.metrics import confusion_matrix, classification_report, precision_recall_curve,recall_score, precision_score, roc_auc_score, RocCurveDisplay

# To suppress warning messages for cleaner output
import warnings
warnings.filterwarnings("ignore")

# Packages for descion tree model
from sklearn.tree import DecisionTreeClassifier, plot_tree

# Packages for KMeans
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score

# Packages for hierarchy
from sklearn.cluster import AgglomerativeClustering
from scipy.cluster.hierarchy import dendrogram, linkage

Load Dataset¶

In [40]:
# --- Load the dataset from a CSV file and asign to pandas dataframe ---
df = pd.read_csv('online_gaming_behavior_dataset.csv')

# Preview the first few rows
df.head()
Out[40]:
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

Investigate The Dataset¶

In [41]:
# Display summary of your dataset’s structure
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
In [5]:
# Check for unique values in each column
df.nunique()
Out[5]:
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¶

  • The dataset contains 40,034 player records over 13 columns.

  • All columns show non-null counts equal to the total number of rows meaning there are no missing values.

  • Based on the number of unique values and data types of each, numerical and categorical can be indefied.

  • Data types include:

    • Numeric columns: Age, PlayTimeHours, InGamePurchases, SessionsPerWeek, AvgSessionDurationMinutes, PlayerLevel, AchievementsUnlocked.
    • Categorical columns: Gender, Location, GameGenre, GameDifficulty, EngagementLevel.
  • The column PlayerID is Unique identifier and is not needed for a prediction, this will dropped.

  • The target variable EngagementLevel is categorical with three possible values, High, Medium, and Low, confirming this is a multi-class classification problem.


Drop columns not needed¶

Drop columns that do not describe anything about the player’s behaviour. PlayerID is not needed because it is just a unique refrence for each player

In [6]:
# Drop columns not needeed for predictions
df=df.drop(['PlayerID'],axis=1)  # axis 1 means column 

Split features into numerical & categorical columns¶

Next split features into numerical and categorical columns so that each type can be processed correctly

In [7]:
# --- Creating numerical columns ---
num_cols = [
    'Age',
    'PlayTimeHours',
    'InGamePurchases',
    'SessionsPerWeek',
    'AvgSessionDurationMinutes',
    'PlayerLevel',
    'AchievementsUnlocked'
]

# --- Creating categorical columns ---
cat_cols = [
    'Gender',
    'Location',
    'GameGenre',
    'GameDifficulty',
    'EngagementLevel'   # Target kept for data exploration (will be separated later)
]

Exploratory Data¶

Univariate analysis of numerical columns¶

In [8]:
# summary statistics for each numerical column
df[num_cols].describe().T
Out[8]:
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
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

Observations¶

  • The average age of players is approximately 32 years, ranging from 15 to 49. The middle 50% fall between 23 and 41 years, showing a wide age spread.

  • Players spend an average of 12 hours per week playing. Playtime ranges from nearly 0 (0.0001) to 24 hours, with 50% of players logging between 6 and 18 hours weekly.

  • Around 20% of players make in-game purchases, which is a relatively small portion of the player base.

  • On average, players log in about 9.5 times per week. The middle 50% play between 4 and 14 sessions, while some players reach up to 19 sessions per week.

  • Each gaming session lasts around 95 minutes on average, with durations ranging from 10 to 179 minutes. The middle 50% of sessions are between 52 and 137 minutes, indicating that players tend to engage in longer play sessions.

  • The average player level is around 50, with levels ranging from 1 to 99.

  • Players have unlocked an average of 25 achievements out of a maximum of 49, indicating broad participation in achievement systems.


Distributions¶

Now We check the distributions of each feature of the number coloumns to understand how the data is spread and determine which features may be useful or ane issue for modelling

In [9]:
# Creating histograms
df[num_cols].hist(figsize=(14,14))
plt.show()
No description has been provided for this image

Observations¶

  • The Age distribution is mostly evenly spread out, showing that the game appeals consistently across all adult age groups rather than being focused on a specific age range.
  • PlayTimeHours is evenly distributed, suggesting an even mix of casual, medium, and serious players.
  • InGamePurchases is right-skewed, showing that most players do not spend money in-game, with only a small minority (20%) making purchases.
  • SessionsPerWeek are evenly spread, meaning players vary widely in their weekly activity levels; there is no strong clustering in any of the bins.
  • Similarly, AvgSessionDurationMinutes has an even distribution across all ranges, meaning players engage in both short and long play sessions.
  • PlayerLevel is also evenly distributed across the range, suggesting a balanced mix of new players, advancing players, and highly experienced players.
  • Finally, AchievementsUnlocked is evenly distributed across the whole range. This suggests a balanced mix of players from low progress to highly advanced players.

The data set has mostly evenly spread features (Age, PlayTimeHours, SessionsPerWeek, AvgSessionDurationMinutes, PlayerLevel, AchievementsUnlocked). The even distributions mean the dataset has a full range of behaviours, allowing a model to separate engagement levels if significant differences exist.


Univariate analysis for categorical features¶

Now let's analyse how each category is distributed to identify any imbalances and understand the data structure.

In [10]:
# Loop through each categorical column and print the percentage distribution of its categories,
for i in cat_cols:
    print(df[i].value_counts(normalize=True))
    print('')
    print('----'*20) #Separate for readability
    print('')
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

--------------------------------------------------------------------------------

GameDifficulty
Easy      0.49995
Medium    0.30002
Hard      0.20003
Name: proportion, dtype: float64

--------------------------------------------------------------------------------

EngagementLevel
Medium    0.483939
High      0.258181
Low       0.257881
Name: proportion, dtype: float64

--------------------------------------------------------------------------------

Observations¶

  • Approximately 60% of players are male and 40% are female, indicating a moderately male-dominant audience, although there is still significant female representation.

  • The majority of players are located in the USA (40%), followed by Europe (30%) and Asia (20%).

  • Game genres are well-balanced, with Sports, Action, Strategy, Simulation, and RPG each attracting roughly 20% of players. This suggests that there is no single genre that is predominantly favored by the majority.

  • Nearly 50% of players select easy difficulty, while 30% opt for Medium and 20% choose Hard, indicating that most players prefer a more accessible gaming experience.

  • Low and medium engagement levels account for approximately 74% of all players, with only 26% demonstrating high engagement. This suggests that while many players are active, they are not highly committed. Other obersavtions shows a balanced distribution at both low and high engagement levels with 26% falling in each.


Bivariate and Multivariate analysis¶

Now lets check how out target variable "EngagementLevel" relates to the categorical variables.

In [11]:
for col in cat_cols:
    if col != 'EngagementLevel':
        
        # Create a percentage table showing how EngagementLevel is distributed within each feature.
        engagement_table = (
            pd.crosstab(df[col], df['EngagementLevel'], normalize='index')
            .mul(100)
        )

        # Plot the distribution using a stacked bar chart.
        ax = engagement_table.plot(
            kind='bar',
            figsize=(9, 5),
            stacked=True
        )
        
        # Add percentage labels on the bars
        for c in ax.containers:
            ax.bar_label(c, fmt='%.1f%%', label_type='center') 
        
        # Labels and titles
        plt.title(f'Engagement Level Distribution by {col}')
        plt.ylabel('Percentage (%)')
        plt.xlabel(col)
        plt.legend(title='EngagementLevel')
        
        plt.tight_layout()
        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¶

  • Engagement levels are almost equal across genders, with High engagement at 26%, Low at 26%, and Medium at 48% for both males and females. This indicates that gender has no significant impact on engagement behaviour in this dataset.

  • With all locations USA, Europe, Asia, and Other, the proportions of High, Medium and Low the engagement are nearly the same, showing that player engagement is consistent regardless of location.

  • All game genres, Action, RPG, Simulation, Sports, and Strategy show similar engagement distributions, with Medium engagement consistently around 48%, Low around 25 to –26%, and High around 25 to 26%. This indicates that game preference does not significantly influence how engaged players are.

  • Engagement remains stable across difficulty settings, with Easy, Medium, and Hard modes. This indicates that difficulty selection does not play a significant factor in determining engagement level.

Across all categorical variables Gender, Location, GameGenre and GameDifficulty, engagement levels are very steady, This sugges that none of these have meaningfully effec on engagement.


Checking relationship between Engagement Levels and Numerical featuers¶

In [12]:
# Calculate the mean of each numerical feature for High, Medium, and Low engagement groups
df.groupby('EngagementLevel')[num_cols].mean()
Out[12]:
Age PlayTimeHours InGamePurchases SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked
EngagementLevel
High 31.920085 12.069238 0.206463 14.254547 131.921827 50.823723 25.095975
Low 31.896939 12.104915 0.197307 4.530511 66.882119 46.101414 22.661565
Medium 32.082120 11.957503 0.199752 9.553267 89.856405 50.926293 25.216424

Observations¶

  • High-engagement players spend significantly longer in each session, averaging 132 minutes, compared to medium-engagement players who average 90 minutes and low-engagement players who average 67 minutes. This indicates that session duration is a strong sign of engagement, with highly engaged players spending almost twice as long per session as low-engagement users.

  • High-engagement players also have the highest activity frequency, averaging 14 sessions per week, compared to 9.5 for medium and just 4.5 for low-engagement players. This shows that session frequency is strongly linked to player engagement and is likely a good predictive behavioural features.

  • PlayerLevel is much lower for low-engagement players, averaging 46 compared to 51 for both medium and high-engagement players. This suggests that players who do not progress or level up as quickly are more likely to show lower engagement.

  • AchievementsUnlocked follow a similar pattern, with high and medium engagement players unlocking around 25 achievements compared to 22.6 for low-engagement players. This supports the idea that progression is strongly associated with higher engagement.

  • The average age is almost identical across all engagement levels, indicating that age is not a significant factor in player engagement within this dataset.

  • Total PlayTimeHours is also very similar across groups, suggesting that total hours played are not as crucial as session length and weekly activity patterns when explaining engagement.

  • Finally, InGamePurchases show only minimal differences between high (0.20), medium (0.20), and low (0.19) engagement players, indicating that spending behaviour is not strongly associated with engagement when looking at averages.


Correlation between all numerical variables¶

Lets creates a heatmap showing how strongly each numerical feature is related to the others, helping identify understand behavioural relationships in the dataset.

In [13]:
# Plotting the correlation between numerical variables
plt.figure(figsize=(15,8))
sns.heatmap(df[num_cols].corr(), annot=True, fmt='0.2f', cmap='YlOrRd')
Out[13]:
<Axes: >
No description has been provided for this image

Observations¶

The heatmap shows that there is no strong correlation between any of the numerical features, as all correlation values are close to zero. This suggests that the metrics measured vary independently rather than moving in predictable patterns together.

  • Age shows almost no correlation with any gameplay behaviour, suggesting that players of different ages play in very similar ways in terms of time spent, progression, or purchase activity.

  • PlayTimeHours does not correlate strongly with SessionsPerWeek or AvgSessionDurationMinutes, suggesting that total playtime is not directly influenced by the number of sessions or the duration of sessions.

  • PlayerLevel and AchievementsUnlocked have almost zero correlation, meaning that progression through levels and unlocking achievements occur separately, rather than achievements naturally increasing alongside levels.

  • InGamePurchases also shows no correlation with engagement. This suggests that spending is not tied to how players engage with the game.


Model Building¶

Approach¶

  1. Data Preparation - Clean and transform the data by encoding categorical variables, scaling numerical features.

  2. Train–Test Split - Split the dataset into training and test sets for exampple 70/30.

  3. Model Training - Fit machine learning models on the training data so they can learn the relationship between features and engagement levels.

  4. Model Tuning - Adjust hyperparameters to improve performance.

  5. Model Testing - Evaluate the final model on the unseen test set using metrics such as accuracy, precision, recall, F1-score.

1) Data Preparation¶

In [14]:
# Step 1
# Create dummy variables for categorical features using the get_dummies pandas function
# Convert categorical columns into numerical because machine learning models cannot work with text
df = pd.get_dummies(
    data=df,
    columns=['Gender', 'Location', 'GameGenre', 'GameDifficulty'], 
    drop_first=True
)

# -------------------------------------------------------------

# Step 2 - Encode the target variable (EngagementLevel)
# Convert the target variables  into numbers so the model can
# learn to classify High, Medium, and Low.
engagement_mapping = {'Low': 0, 'Medium': 1, 'High': 2}
df['EngagementLevel'] = df['EngagementLevel'].map(engagement_mapping)

# -------------------------------------------------------------

# Step 3 - Separate independent (X) and dependent (y) variables
# X = features used for prediction
# y = what we are trying to predict (EngagementLevel)

X = df.drop(columns=['EngagementLevel'])
y = df['EngagementLevel']

# -------------------------------------------------------------

# Step 4-  Split the data into training and test sets
X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.3,        # 70% train, 30% test
    random_state=1,       # ensures consistent results
    stratify=y            # keeps class ratios balanced
)

Scaling the Data¶

Because the numerical features are on very different scales — for example, Age ranges from 15 to 49 while AvgSessionDurationMinutes ranges from 10 to 179 — some models that rely on mathematical distance can be unfairly influenced by large-scale features.

StandardScaler solves this by converting each feature into a z-score, giving all numerical variables a comparable scale. This ensures that no feature dominates purely because of its size and that all inputs contribute more evenly during model training.

Although tree-based models do not require scaling, applying StandardScaler will not harm their performance and keeps the data consistent across all models.

In [15]:
# Step 1 - Initialise the scaler
# StandardScaler transforms each feature so it has mean=0 and std=1
scaler = StandardScaler()

# -----------------------------------------------

# Step 2 -  Fit the scaler on the training data only
X_train_scaled = scaler.fit_transform(X_train)

# Convert back to DataFrame for readability
X_train_scaled = pd.DataFrame(X_train_scaled, columns=X_train.columns)

# -----------------------------------------------
# Step 3 - Transform the test data
X_test_scaled = scaler.transform(X_test)

# Convert back to DataFrame for readability
X_test_scaled = pd.DataFrame(X_test_scaled, columns=X_test.columns)

Model evaluation criterion¶

The model can make a number of types of wrong predictions:

  • Predicting High engagement when the player is actually Low

  • Predicting Low when the player is actually High

  • Predicting Medium when the player belongs to High or Low

Although all misclassifications matter, some have a greater impact than others. The most critical error in this project is predicting that a player has Medium or High engagement when they are actually Low engagement. Players in the Low category are at the highest risk of becoming inactive or leaving the game. Failing to identify these players could result in:

  • No chance for intervention

  • Reduced retention

It is essential that the model identifies Low-engagement players correctly. To minimise this risk, the project focuses on Recall for the “Low” engagement class.

A classifaction report and confusion matrix will be need to evaluate the modes. below is a function that can be called to display these

In [16]:
# Function to Prints classification report and displays a confusion matrix heatmap
def metrics_score(actual, predicted):
   
    # Print precision, recall and F1-score for each class
    print(classification_report(actual, predicted))
    
    # Generate confusion matrix
    cm = confusion_matrix(actual, predicted)
    
    # Plot the matrix
    plt.figure(figsize=(8,5))
    sns.heatmap(
        cm, 
        annot=True, 
        fmt='g',
        cmap='Blues',
        xticklabels=['High', 'Medium', 'Low'],
        yticklabels=['High', 'Medium', 'Low']
    )
    
    plt.title('Confusion Matrix')
    plt.xlabel('Predicted')
    plt.ylabel('Actual')
    plt.show()

SVG Model¶

Train & compare different SVM kernels¶

In this section, three Support Vector Machine (SVM) models will be trained and compared using linear, RBF, and polynomial kernels. The objective is to identify the best-performing kernel and then apply hyperparameter tuning to further optimise its performance.

Testing multiple kernels allows us to identify which one best captures the patterns in the data before tuning

Linear Kernel¶

In [17]:
# Create a Linear SVM model. The random_state is set to 1 so the results are reproducible each time the code is run.
svm_linear = SVC(kernel='linear', random_state=1)

# Fit model
svm_linear.fit(X_train_scaled, y_train)

# Predict on train
y_pred_train_linear = svm_linear.predict(X_train_scaled)
print("\nTrain Results (Linear):")
metrics_score(y_train, y_pred_train_linear)

# Predict on test
y_pred_test_linear = svm_linear.predict(X_test_scaled)
print("\nTest Results (Linear):")
metrics_score(y_test, y_pred_test_linear)
Train Results (Linear):
              precision    recall  f1-score   support

           0       0.79      0.71      0.75      7227
           1       0.81      0.87      0.84     13561
           2       0.89      0.85      0.87      7235

    accuracy                           0.82     28023
   macro avg       0.83      0.81      0.82     28023
weighted avg       0.83      0.82      0.82     28023

No description has been provided for this image
Test Results (Linear):
              precision    recall  f1-score   support

           0       0.79      0.71      0.75      3097
           1       0.81      0.87      0.84      5813
           2       0.89      0.85      0.87      3101

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

No description has been provided for this image

Observations¶

Accuracy Train: 0.82 and Test: 0.82

Recall For Class 0 (Low) Train: 0.71 and Test 0.71

  • Train and test performance are almost identical, which means the linear SVM is not overfitting. Overfitting is when the model learns the training data too closely, so it does well on the training set but performs poorly on new data.

  • Recall for the Low-engagement class (0) is around 0.71, meaning the model is still missing about 29% of Low-engagement players. This is a key audience that we need to be the most accurate for.

  • Medium (class 1) and High (class 2) have good recall (0.87 and 0.85), so the model is better at correctly identifying Medium/High engagement than Low.

Overall, the model is reliable, but it still needs improvement in catching Low-engagement players.

RBF Kernel¶

In [18]:
# Create a rbf SVM model. The random_state is set to 1 so the results are reproducible each time the code is run.
svm_rbf = SVC(kernel='rbf', random_state=1, probability=True)

# Fit model
svm_rbf.fit(X_train_scaled, y_train)

# Predict on train
y_pred_train_rbf = svm_rbf.predict(X_train_scaled)
print("\nTrain Results (RBF):")
metrics_score(y_train, y_pred_train_rbf)

# Predict on test
y_pred_test_rbf = svm_rbf.predict(X_test_scaled)
print("\nTest Results (RBF):")
metrics_score(y_test, y_pred_test_rbf)
Train Results (RBF):
              precision    recall  f1-score   support

           0       0.91      0.85      0.88      7227
           1       0.89      0.95      0.92     13561
           2       0.93      0.87      0.90      7235

    accuracy                           0.91     28023
   macro avg       0.91      0.89      0.90     28023
weighted avg       0.91      0.91      0.91     28023

No description has been provided for this image
Test Results (RBF):
              precision    recall  f1-score   support

           0       0.89      0.82      0.86      3097
           1       0.87      0.93      0.90      5813
           2       0.92      0.85      0.88      3101

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

No description has been provided for this image

Observations¶

Accuracy Train: 0.91 and Test: 0.89

Recall For Class 0 (Low) Train: 0.85 and Test 0.82

  • The RBF SVM shows better performance over Linear, with accuracy on the test data increasing from 0.82 (linear) to 0.89.

  • Recall for the Low-engagement class improves from 0.71 (linear) to 0.82, meaning the RBF model correctly identifies more at-risk players.

  • The gap between train (0.91) and test (0.89) accuracy is small.

  • Medium and High classes also have good recall (0.93 and 0.85 on the test set), showing that the model performs well at all engagement levels.

Polynomial¶

In [19]:
#Create a polynomial SVM 
svm_poly3 = SVC(kernel='poly', degree=3, random_state=1)

# Fit model
svm_poly3.fit(X_train_scaled, y_train)

# Predict on train
y_pred_train_poly3 = svm_poly3.predict(X_train_scaled)
print("\nTrain Results (Polynomial):")
metrics_score(y_train, y_pred_train_poly3)

# Predict on test
y_pred_test_poly3 = svm_poly3.predict(X_test_scaled)
print("\nTest Results (Polynomial):")
metrics_score(y_test, y_pred_test_poly3)
Train Results (Polynomial):
              precision    recall  f1-score   support

           0       0.90      0.80      0.85      7227
           1       0.85      0.94      0.89     13561
           2       0.92      0.83      0.87      7235

    accuracy                           0.88     28023
   macro avg       0.89      0.86      0.87     28023
weighted avg       0.88      0.88      0.88     28023

No description has been provided for this image
Test Results (Polynomial):
              precision    recall  f1-score   support

           0       0.88      0.78      0.83      3097
           1       0.83      0.93      0.88      5813
           2       0.91      0.82      0.86      3101

    accuracy                           0.86     12011
   macro avg       0.87      0.84      0.86     12011
weighted avg       0.86      0.86      0.86     12011

No description has been provided for this image

Observations¶

Accuracy Train: 0.88 and Test: 0.86

Recall For Class 0 (Low) Train: 0.80 and Test 0.78

The polynomial SVM performs better than the linear model, but worse than the RBF model on both accuracy and recall.

Low-class recall improves compared to linear SVM (from 0.71 → 0.78 on the test set), but still does not get to the level of the RBF model (0.82).

Train vs test accuracy (0.88 vs 0.86) is close, again showing there is no overfitting.


Overall Comparison & Summary¶

All three SVMs have relatively high accuracy; however, RBF stands out as the best.

For this project, the focus is on recall for the Low-engagement class, because missing Low players means missed retention opportunities.

The RBF SVM is the best-performing kernel for Low recall. Not only does it have the highest recall among the three kernels, but it also achieves the best overall accuracy and F1-score. This makes it the most suitable model to advance in hyperparameter tuning.


Hyperparameter Tuning¶

Hyperparameter tuning is about adjusting the model’s settings to help it learn better and improve accuracy.

For this we will focus on tuning the RBF model to see if we can improve it.

In [20]:
# First We start with a base RBF SVM model
svm_rbf_base = SVC(
    kernel='rbf',
    probability=True,
    random_state=1
)

# Create a grid of hyperparameters to test
param_grid = {
    'C': [0.1, 1, 10],
    'gamma': ['scale', 0.1, 0.01],
    'class_weight': [None, 'balanced']
}

# GridSearch
# Scoring = recall_macro means we focus on calculating recall for each class (Low, Medium, High)
# cv=3 means 3-fold cross-validation, split training data into 3 parts, train on 2 parts, validate on 1 part
# n_jobs=-1 means use all CPU cores to enable to model to run faster

grid_search = GridSearchCV(
    estimator=svm_rbf_base,
    param_grid=param_grid,
    scoring='recall_macro',  
    cv=3,
    n_jobs=-1
)

# ----------------------------------------------------------------------------------------------------

# Run the hyperparameter search, tries all parameter combinations
grid_search.fit(X_train_scaled, y_train)

# ----------------------------------------------------------------------------------------------------

# Show the best parameters and best CV score fromt he grid search
print("\nBest parameters found: "), print(grid_search.best_params_)
print("\nBest cross-validated macro recall:", round(grid_search.best_score_, 3))

# Use the best model found by GridSearchCV,  
# This gives a fully-trained SVM using the best hyperparameters.
# This avoids the need to manually refit the model with the best parameters.
svm_rbf_tuned = grid_search.best_estimator_

# ----------------------------------------------------------------------------------------------------

# Evaluate the tuned model on train and test sets
# Predict on train
print("\nTrain Results (Tuned RBF):")
y_pred_train_rbf_tuned = svm_rbf_tuned.predict(X_train_scaled)
metrics_score(y_train, y_pred_train_rbf_tuned)

## Predict on test
print("\nTest Results (Tuned RBF):")
y_pred_test_rbf_tuned = svm_rbf_tuned.predict(X_test_scaled)
metrics_score(y_test, y_pred_test_rbf_tuned)
Best parameters found: 
{'C': 10, 'class_weight': 'balanced', 'gamma': 0.01}

Best cross-validated macro recall: 0.879

Train Results (Tuned RBF):
              precision    recall  f1-score   support

           0       0.82      0.90      0.86      7227
           1       0.93      0.86      0.89     13561
           2       0.87      0.91      0.89      7235

    accuracy                           0.88     28023
   macro avg       0.87      0.89      0.88     28023
weighted avg       0.89      0.88      0.88     28023

No description has been provided for this image
Test Results (Tuned RBF):
              precision    recall  f1-score   support

           0       0.82      0.89      0.85      3097
           1       0.92      0.85      0.89      5813
           2       0.87      0.90      0.88      3101

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

No description has been provided for this image

Precision-Recall Curve & AUC-ROC score¶

In the previous section, the RBF SVM model was trained and evaluated using its default decision threshold of 0.5. However, this default threshold is not always optimal for the project's goal, which is to accurately identify players with Low engagement.

To improve the accuracy, it may be helpful to adjust the probability threshold the model uses to decide whether a player should be classified as Low or Not-Low.

Threshold tuning works by:

  • Converting the multiclass data into a binary problem of Low (1) vs Not-Low (0). Threshold tuning can not work with multiclass.
  • Plotting a graph to inspect how precision and recall change at different probability thresholds.
  • Selecting a threshold that provides a suitable balance

This section plots a Precision–Recall vs Threshold curve and then applies a chosen threshold to both the training and test sets to evaluate the model's performance.

In [21]:
# Convert data to binary  (Low vs Not-Low)
# This checks every value in y_train and y_test:
#     If the value is 0 (Low) then TRUE is retunred
#     if the value is 1 or 2 → then FALSE is retunred
#     Finally convert True to 1 (Low) and False to 0 (Medium and High)
y_train_low = (y_train == 0).astype(int)
y_test_low  = (y_test == 0).astype(int)

# ----------------------------------------------------
# identify Probability of the LOW class

#Finds which column of predict_proba relates to Low engagement
low_index = list(svm_rbf_tuned.classes_).index(0)

#Gets the predicted probability for train and test
y_scores_train = svm_rbf_tuned.predict_proba(X_train_scaled)[:, low_index]
y_scores_test  = svm_rbf_tuned.predict_proba(X_test_scaled)[:, low_index]

# -----------------------------------------------------

# AUC-ROC score
auc = roc_auc_score(y_test_low, y_scores_test)
print("AUC-ROC (Test):", round(auc, 3))

# Plot result
RocCurveDisplay.from_predictions(y_test_low, y_scores_test)
plt.title("ROC Curve (Low vs Not-Low)")
plt.show()

# ----------------------------------------------------

# Plot values of precisions, recalls, and thresholds
precisions, recalls, thresholds = precision_recall_curve(y_train_low, y_scores_train)

plt.figure(figsize=(8,5))
plt.plot(thresholds, precisions[:-1], label='Precision', linestyle='--')
plt.plot(thresholds, recalls[:-1], label='Recall', linestyle='--')
plt.xlabel("Threshold")
plt.ylabel("Score")
plt.title("Precision–Recall vs Threshold (Low Engagement)")
plt.legend()
plt.grid(True)
plt.show()
AUC-ROC (Test): 0.94
No description has been provided for this image
No description has been provided for this image

Observations (Precision–Recall vs Threshold Curve & ROC Curve)¶

  • An AUC of 0.94 indicates very good model performance, meaning the tunned model is very good at separating Low-engagement players from Mediam / high engaged players.
  • The chart shows that higher thresholds increase precision, but reduce recall, indicating the model becomes more strict and starts missing low-engaged players.
  • The curves cross around 0.42, showing a balanced point between precision and recall.
  • For this project, recall is prioritised because missing a Low-engagement player (false negative) means missing a chance to intervene. False positives, predicting Low when they aren't Low, are less impactful to the business than missing a true Low-engagement player.
  • Next, we will test a fixed threshold of 0.34, which should give a higher recall whilst maintaining an acceptable precision.
In [22]:
# Assign and apply threshold
threshold = 0.34

#---------------------------------------------------------------
             
# Apply threshold (Train)
y_pred_train_low = (y_scores_train > threshold).astype(int)

# Use classifaction report function to output results
print("\nTRAIN Results (Low vs Not-Low):")
print(classification_report(
    y_train_low, 
    y_pred_train_low, 
    target_names=['Not-Low', 'Low']
))

# ----------------------------------------------------

# Apply threshold (TEST)

y_pred_test_low = (y_scores_test > threshold).astype(int)

print("\nTEST Results (Low vs Not-Low):")
print(classification_report(
    y_test_low, 
    y_pred_test_low, 
    target_names=['Not-Low', 'Low']
))
TRAIN Results (Low vs Not-Low):
              precision    recall  f1-score   support

     Not-Low       0.96      0.92      0.94     20796
         Low       0.80      0.90      0.85      7227

    accuracy                           0.92     28023
   macro avg       0.88      0.91      0.90     28023
weighted avg       0.92      0.92      0.92     28023


TEST Results (Low vs Not-Low):
              precision    recall  f1-score   support

     Not-Low       0.96      0.92      0.94      8914
         Low       0.80      0.90      0.85      3097

    accuracy                           0.92     12011
   macro avg       0.88      0.91      0.90     12011
weighted avg       0.92      0.92      0.92     12011

Observations (Threshold-Tuned Results)¶

  • The updated model now achieves a high recall for Low engagement.
  • Precision for Low is lower at 0.80, but it is acceptable because false positives are less harmful than missing actual Low-engagement players.
  • The training and test results are almost identical, indicating that the model is not overfitting.
  • Overall accuracy is higher at 0.92 for test and train.
  • Threshold tuning has clearly improved the model

Overall SVG Model Observations¶

  • The RBF kernel performed best out of the three tested SVM models.
  • Hyperparameter tuning helped improve the RBF model slightly, especially for recall for the Low-engagement class.
  • Threshold tuning significantly increased recall for the Low-engagement class. This will help with the business goal of identifying low-engaged players early.
  • Higher recall and lower precision for the Low class is acceptable, as missing a Low-engagement player is more of a problem.
  • Overall, the final model is strong at predicting player engagement. Now we need to explore Decision Trees.

Decision trees¶

A Decision Tree is a machine learning model that splits the data into decision rules to predict the target class. After evaluating the SVM model, this section examines a Decision Tree to compare its performance.

Create and Fit Model¶

In [23]:
# Create Discion tree model
dtree = DecisionTreeClassifier(
    random_state=1 # make sure the results are not random on each run
)

# ---------------------------------------------------------------------------

# Fit the model on the TRAIN data.
dtree.fit(X_train_scaled, y_train)

# ------------------------------------------------------------

# Evaluation Decision Tree On Test Data

# ---- Train performance ----
print("\nDescision Tree Train Results:")
y_pred_train_dt = dtree.predict(X_train_scaled)
metrics_score(y_train, y_pred_train_dt) # Call metrics function for classifaction report and confusion matrix


# ---- Test performance ----
print("\nDescision Tree TEST Results:")
y_pred_test_dt = dtree.predict(X_test_scaled)
metrics_score(y_test, y_pred_test_dt) # Call metrics function for classifaction report and confusion matrix
Descision Tree Train Results:
              precision    recall  f1-score   support

           0       1.00      1.00      1.00      7227
           1       1.00      1.00      1.00     13561
           2       1.00      1.00      1.00      7235

    accuracy                           1.00     28023
   macro avg       1.00      1.00      1.00     28023
weighted avg       1.00      1.00      1.00     28023

No description has been provided for this image
Descision Tree TEST Results:
              precision    recall  f1-score   support

           0       0.79      0.80      0.80      3097
           1       0.87      0.85      0.86      5813
           2       0.80      0.81      0.81      3101

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

No description has been provided for this image

Observations¶

  • The Decision Tree achieved 100% accuracy on the training set, which indicates severe overfitting. This means the model has memorised the training data rather than learning general patterns.

  • On the test data, accuracy drops to 83%, confirming that the model does not generalise well. Although performance across the three classes is relatively balanced, with recall values between 0.80 and 0.85.

  • The recall for the Low-engagement class is 0.80, meaning the model successfully identifies a significant number of low-engaged players, but still misses some, leaving room for improvement.

  • Overall, the gap between train and test performance clearly shows overfitting. This justifies the need for hyperparameter tuning to try and control the overfitting and hopefully increase performance.

Display Tree¶

To better understand how the Decision Tree makes predictions, a smaller version of the tree is visualised below. This view highlights the key features and decision rules the model has used to separate Low, Medium, and High engagement players.

In [24]:
# The full Decision Tree is very large and difficult to read.
# By setting max_depth=3, we limit the tree to only the top levels.
# NOTE - This is just to understand how a Tree works and this model should not be used for prediction
dtree_small = DecisionTreeClassifier(
    random_state=1,   # ensures results are the same every run 
    max_depth=3       # limits depth of the tree
)

# Fit the shallow tree on the training data
dtree_small.fit(X_train_scaled, y_train)

# Plot the tree
plt.figure(figsize=(20, 10)) 

plot_tree(
    dtree_small,
    feature_names=X_train_scaled.columns,  # show feature names in the nodes
    class_names=['Low', 'Medium', 'High'], # label each class clearly
    filled=True,       # colour the nodes based on the class
)

plt.title("Decision Tree (max_depth=3)")
plt.show()
No description has been provided for this image

Hyperparameter tuning¶

To improve model generalisation and reduce overfitting, hyperparameter tuning is performed on the Decision Tree using a grid search

In [25]:
# To help witht he overfitting. We use a Grid Search to search through different settings and
# choose the model with the best results.

# Set up the model
dtree = DecisionTreeClassifier(
    random_state=1,   # keep model results reproducible
    class_weight='balanced'

)

# Define the hyperparameter grid.
param_grid_dt = {
    'criterion': ['gini', 'entropy', 'log_loss'],   # different ways to measure how good a split is
    'max_depth': [3, 5, 10, None],                  # limit how deep the tree can go (None = no limit)
    'min_samples_split': [2, 20, 50],               # minimum samples needed to split a node
    'min_samples_leaf': [1, 10, 50],                # minimum samples allowed in a leaf
    'max_features': [None, 'sqrt', 'log2'],         # number of features to consider at each split       
}

# Set up grid search
grid_search_dt = GridSearchCV(
    estimator=dtree,          # assign model to tune
    param_grid=param_grid_dt, # assign hyperparameters to try
    scoring='recall_macro',   # choose model with best overall recall
    cv=3,                     # 3-fold cross-validation splits the training data into 3 parts / train on two and validate on the third
    n_jobs=-1,                # use all CPU cores to run
)

#Preform grid search
grid_search_dt.fit(X_train_scaled, y_train)

#Print best parameterts
print("\nBest hyperparameters found:")
print(grid_search_dt.best_params_)

# assign best model results
best_dtree = grid_search_dt.best_estimator_
Best hyperparameters found:
{'criterion': 'gini', 'max_depth': 10, 'max_features': None, 'min_samples_leaf': 10, 'min_samples_split': 2}

Evaluation of tuned Decision Tree¶

In [26]:
# Evaluation of tuned Decision Tree

# Train performance for tuned tree
print("\nTuned Decision Tree TRAIN Results ===")
y_pred_train_best_dt = best_dtree.predict(X_train_scaled)
metrics_score(y_train, y_pred_train_best_dt)

# Test performance for tuned tree
print("\nTuned Decision Tree TEST performance")
y_pred_test_best_dt = best_dtree.predict(X_test_scaled)
metrics_score(y_test, y_pred_test_best_dt)

# ------------------------------------------------------------

# Find the probability column that belongs to the 'Low' class
low_idx = list(best_dtree.classes_).index(0)

# use redict_proba to get predicted probabilities for the Low-engagement class
y_scores_dt = best_dtree.predict_proba(X_test_scaled)[:, low_idx]

# Calculate AUC-ROC to show how well the model separates Low vs Not-Low
auc_score = roc_auc_score(y_test_low, y_scores_dt)
print("AUC-ROC - Low vs Not-Low:", round(auc_score, 3))

# Plot ROC curve for visual evaluation
RocCurveDisplay.from_predictions(y_test_low, y_scores_dt)
plt.title("AUC-ROC Curve (Low vs Not-Low)")
plt.show()
Tuned Decision Tree TRAIN Results ===
              precision    recall  f1-score   support

           0       0.89      0.90      0.90      7227
           1       0.93      0.93      0.93     13561
           2       0.92      0.90      0.91      7235

    accuracy                           0.92     28023
   macro avg       0.91      0.91      0.91     28023
weighted avg       0.92      0.92      0.92     28023

No description has been provided for this image
Tuned Decision Tree TEST performance
              precision    recall  f1-score   support

           0       0.88      0.89      0.89      3097
           1       0.92      0.92      0.92      5813
           2       0.91      0.89      0.90      3101

    accuracy                           0.91     12011
   macro avg       0.90      0.90      0.90     12011
weighted avg       0.91      0.91      0.91     12011

No description has been provided for this image
AUC-ROC - Low vs Not-Low: 0.934
No description has been provided for this image

Observations¶

  • Test accuracy improved from 0.83 to 0.91 after tuning.

  • Training and test performance are now very close (0.92 vs 0.91), showing reduced overfitting.

  • Recall for the Low-engagement class improved significantly from 0.80 to 0.89, meaning the model identifies more low-engaged players.

  • All classes now have more balanced performance, with recalls around 0.89–0.92.

  • Overall, tuning produced a more accurate and stable result, improving the high overfitting observed without tuning.

  • The steep ROC curve and AUC of approx 0.93 shows that the Decision Tree strongly separates Low-engagement players from others.


Comparison of SVM and Decision Tree¶

Overall accuracy:

  • SVM (RBF): 0.88
  • Decision Tree: 0.91

Bear recall on Low engagement:

  • Tuned SVM: 0.90
  • Tuned Decision Tree: 0.89

AUC-ROC for “Low vs Not-Low”:

  • SVM: 0.94
  • Decision Tree: 0.93

Generalisation:

  • Both tuned models have very similar train and test performance, confirming no overfitting

Conclusion: Both models perform very well, with the decision tree showing better overall accuracy but slightly lower recall results on the key guidance for detecting low-engaged players. Another observation is that running the decision tree is significantly faster. However, the test shows that tuned SVG performs slightly better and would be the preferred choice. As the data grows in the future it may be benificial to try both models again to ensure results are the same.

Business Insights from the Supervised Learning Models¶

  • The supervised models were designed to identify players with a low engagement level, helping the business understand which players are at risk of reducing activity or leaving the game.
  • The models focus on recall for the low-engagement class, meaning they prioritise correctly identifying players who are at risk.
  • Players predicted as low-engagement could be targeted with retention techniques for example, daily rewards, simplified gameplay, or reminders to return.
  • Medium-engagement players identified by the model may benefit from promotions to increase their activity, such as progression incentives.
  • High-engagement players can be encouraged to remain active by offering advanced content, for example "expert" challenges.

Unsupervised Learning: Player Segmentation with Clustering¶

This section uses unsupervised learning to uncover hidden player groups based on behavioural and demographic features. Unlike the supervised models (SVM and Decision Tree), clustering does not use engagement labels. Instead, it finds natural patterns in the data, helping identify distinct player segments that can support business decisions.

Clustering does not use EngagementLevel because it is an unsupervised method. Instead of predicting labels, it groups players based on similarities in behaviour.

First We reload the original dataset to ensure clustering is performed on unmodified data.

In [27]:
# Read in raw data again to avoid using previous dataframes which have been moddifed
df_cluster = pd.read_csv("online_gaming_behavior_dataset.csv")

Select Behavioural features¶

Six behavioural features were selected because they represent the key features of player activity: time spent, frequency of play, session length, spending, progression, and achievement. They are fully numeric which is best for K-Means as it relies on distances between data points.

In [28]:
# # Define a list of features to be used for clustering 
cluster_features = [
    'Age',
    'PlayTimeHours',
    'SessionsPerWeek',
    'AvgSessionDurationMinutes',
    'InGamePurchases',
    'PlayerLevel',
    'AchievementsUnlocked'
]

# Select only the relevant features from the DataFrame for clustering
X_cluster = df_cluster[cluster_features]

Scale the features¶

K-Means is distance based, so features must be on a similar scale. StandardScaler transforms all selected features to have a mean of 0 and a standard deviation of 1, ensuring that some features do not dominate others

In [29]:
# Initialize StandardScaler
scaler = StandardScaler()

# Fit the scaler to the clustering data and transform it
X_cluster_scaled = scaler.fit_transform(X_cluster)

Elbow Plot¶

The Elbow helps determine a suitable number of clusters by showing how inertia (the within-cluster distance) decreases as k increases. A elbow shape in the curve indicates a point where adding more clusters stops providing significant improvement.

In [30]:
inertia = []                       # Empty list to store inertia (WCSS) f

for k in range(2, 10):             # try k values from 2 up to 9
    km = KMeans(n_clusters=k,      # create K-Means model with k clusters
                random_state=1)
    km.fit(X_cluster_scaled)       # fit model on the scaled behavioural data
    inertia.append(km.inertia_)    

# Plot inertia against k
plt.plot(range(2, 10), inertia, marker='o')
plt.title("Elbow Method")
plt.xlabel("k")                    # number of clusters
plt.ylabel("Inertia")              # total within-cluster distance
plt.grid(True)
plt.show()
  File "c:\Users\WilcoxJo\AppData\Local\anaconda3\Lib\site-packages\joblib\externals\loky\backend\context.py", line 257, in _count_physical_cores
    cpu_info = subprocess.run(
               ^^^^^^^^^^^^^^^
  File "c:\Users\WilcoxJo\AppData\Local\anaconda3\Lib\subprocess.py", line 548, in run
    with Popen(*popenargs, **kwargs) as process:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "c:\Users\WilcoxJo\AppData\Local\anaconda3\Lib\subprocess.py", line 1026, in __init__
    self._execute_child(args, executable, preexec_fn, close_fds,
  File "c:\Users\WilcoxJo\AppData\Local\anaconda3\Lib\subprocess.py", line 1538, in _execute_child
    hp, ht, pid, tid = _winapi.CreateProcess(executable, args,
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
No description has been provided for this image

Observations from the Elbow Plot¶

  • There is a large drop in inertia from k = 2 to k = 3.
  • After k = 4, the inertia curve begins to flatten, showing that additional clusters provide reduced returns.
  • The most noticeable elbow appears around k = 3.
  • The elbow suggests that 3 clusters represent the best balance between simplicity and clustering quality.
  • For this project, k = 3 is an appropriate choice and should capture meaningful differences in player behaviour.

Silhouette score plot¶

The silhouette score measures how well the clusters are separated from each other. Higher scores indicate better defined clusters. By comparing silhouette scores across different values of k, we identify which k results in the strongest overall cluster structure.

In [31]:
sil_scores = []   # Rmpyty list to store silhouette scores for each k

for k in range(2, 10):                         # test k values from 2 to 9
    km = KMeans(n_clusters=k, random_state=1)  # create K-Means model with k clusters
    labels = km.fit_predict(X_cluster_scaled)  # fit model and get cluster labels
    sil_scores.append(                         # calculate how well-separated clusters are
        silhouette_score(X_cluster_scaled, labels)
    )

# Plot silhouette score for each value of k
plt.plot(range(2, 10), sil_scores)
plt.title("Silhouette Scores")
plt.xlabel("k")                  # number of clusters
plt.ylabel("Silhouette Score")   # higher = better cluster separation
plt.grid(True)
plt.show()
No description has been provided for this image

Observations from the Silhouette Scores¶

  • The silhouette score increases from k = 2 to k = 3, showing better-defined clusters at k = 3.
  • k = 3 gives the highest silhouette score overall.
  • After k = 3, the silhouette score drops noticeably at k = 4 and only slightly improves at k = 5.
  • From k = 5 onwards, the scores remain relatively low
  • Larger values of k (6 to 9) do not improve the silhouette score and therefore do not provide better clustering quality.
  • The clear best clustering occurs at k = 3. This result supports the elbow method, which also suggests that around 3 clusters is a suitable choice. Now let's apply this to the model

Fit K-Means with chosen k¶

Based on the elbow and silhouette analysis, k = 3 provides a good balance. We now fit the final K-Means model and assign each player to one of the seven behavioural clusters.

In [32]:
# chosen because the elbow and silhouette plots both indicate k will work best at 3
k_final = 3

# Create the K-Means model with 3 clusters
kmeans_final = KMeans(n_clusters=k_final, random_state=1)

# Fit the model
df_cluster['Cluster'] = kmeans_final.fit_predict(X_cluster_scaled)

Behaviour summary¶

To understand how each cluster behaves, we calculate the average value of each behavioural feature within each group. This reveals the patterns that define each cluster.

In [33]:
cluster_summary = df_cluster.groupby('Cluster')[cluster_features].mean()
cluster_summary
Out[33]:
Age PlayTimeHours SessionsPerWeek AvgSessionDurationMinutes InGamePurchases PlayerLevel AchievementsUnlocked
Cluster
0 32.075835 6.047918 9.545364 95.168815 0.0 50.062508 24.267614
1 31.911249 18.032009 9.368684 94.566548 0.0 49.062520 24.783461
2 31.988807 11.940694 9.530780 94.493222 1.0 50.027609 24.529287

GFenerate a cluster bar chart to show the feature diffrence in each cluste

In [34]:
# Create bar chart for each feature across the 3 clusters
plt.figure(figsize=(12, 6))

cluster_summary.plot(kind='bar', figsize=(12,6))
plt.title("Cluster Behaviour Comparison")
plt.xlabel("Cluster")
plt.ylabel("Average Value")
plt.xticks(rotation=0)
plt.legend(title="Features", bbox_to_anchor=(1.05, 1), loc='upper left')
plt.tight_layout()
plt.show()
<Figure size 1200x600 with 0 Axes>
No description has been provided for this image

Cluster 0: Low-Playtime Regulars

  • This group represents casual but consistent players who enjoy the game but do not spend much time or money.
  • Players in this group have the lowest overall PlayTimeHours.
  • They still play regularly each week and have a similar frequency to other clusters.
  • They make no in-game purchases.
  • Their player level and achievements are similar to those of other clusters.

Cluster 1: High-Playtime Engaged Players

  • These are more engaged players, even though their spending remains minimal.
  • This cluster has the highest PlayTimeHours, clearly differentiating them from the others.
  • They play slightly fewer sessions per week but accumulate more hours, suggesting longer play sessions.
  • Purchases remain at zero, similar to Cluster 0.
  • PlayerLevel and AchievementsUnlocked are similar to the other clusters.

Cluster 2: Moderate-Playtime Players with Occasional Spending

  • This group represents moderate players, with a small indication of willingness to spend.
  • PlayTimeHours sits between Cluster 0 and Cluster 1.
  • Session frequency and duration are similar to the other clusters.
  • This cluster contains the only players who make any in-game purchases.
  • PlayerLevel and AchievementsUnlocked are in line with the rest.

Business Insights from the K-Means Clusters¶

  • Cluster 0: Low-Playtime Regulars

    • Casual players who log in consistently but do not spend much time or money.
    • These players may be at risk of leaving.
    • Creating content that supports casual gameplay may help retain this group.
  • Cluster 1: High-Playtime Engaged Players

    • Players who invest the most time in the game but still spend very little.
    • They may respond well to targeted offers, such as cosmetic items, to encourage spending.
  • Cluster 2: Moderate-Playtime Players with Occasional Spending

    • The only group that shows any in-game purchasing behaviour.
    • These players could be encouraged to increase their playtime through daily login rewards and engagement incentives.

Overall Insight¶

  • The clusters reveal different engagement patterns, and each group can be targeted with specific help with player retention.

Agglomerative Clustering¶

Agglomerative Clustering was used as a second clustering method to check whether similar patterns appear when using a hierarchical approach rather than K-means.

In [35]:
# Agglomerative Clustering uses the same number of clusters as KMeans based on the data analysis
agg = AgglomerativeClustering(n_clusters=3, linkage='ward')
df_cluster['Agg_Cluster'] = agg.fit_predict(X_cluster_scaled)

# Summary of hierarchical clusters
agg_summary = df_cluster.groupby('Agg_Cluster')[cluster_features].mean()
display(agg_summary)
Age PlayTimeHours SessionsPerWeek AvgSessionDurationMinutes InGamePurchases PlayerLevel AchievementsUnlocked
Agg_Cluster
0 37.278620 11.787043 9.422751 87.210790 0.0 44.280459 25.212072
1 31.988807 11.940694 9.530780 94.493222 1.0 50.027609 24.529287
2 25.244751 12.375290 9.500605 104.644296 0.0 56.306242 23.649420
In [36]:
# Create bar chart for each feature across the 3 Agglomerative clusters
plt.figure(figsize=(12, 6))

agg_summary.plot(kind='bar', figsize=(12,6))
plt.title("Agglomerative Cluster Behaviour Comparison")
plt.xlabel("Cluster")
plt.ylabel("Average Value")
plt.xticks(rotation=0)
plt.legend(title="Features", bbox_to_anchor=(1.05, 1), loc='upper left')

plt.tight_layout()
plt.show()
<Figure size 1200x600 with 0 Axes>
No description has been provided for this image

The model produced three groups with similar behavioural patterns to K-Means, However, unlike K-Means, this model also showed Age as another factor

Cluster 0: Older Low-Level Players with Shorter Sessions

  • This group has the highest average age among all clusters.
  • Their PlayTimeHours are average, similar to the other clusters.
  • They have the lowest average PlayerLevel, suggesting slower progression.
  • Session duration is the shortest, meaning brief play sessions.
  • They make no in-game purchases.
  • These players may be more casual, older players who play occasionally but do not progress quickly.

Cluster 1: Average-Age Players with Occasional Spending

  • Age and PlayTimeHours are near the dataset averages.
  • They have an average PlayerLevel.
  • Session duration is average for the dataset.
  • This cluster is the only group that makes any in-game purchases.
  • These players represent a stable middle group of players.

Cluster 2: Young High-Level Players with Long Sessions

  • This group has the youngest players in the dataset.
  • Their PlayTimeHours are higher than those of other clusters.
  • They have the highest PlayerLevel.
  • They have the longest session durations, indicating they enjoy the game.
  • They make no in-game purchases.
  • These players appear to enjoy the game and are very engaged. However, they do not spend.

Overall Insight

  • Agglomerative clustering separates players differently from K-Means and focuses mainly on age, session duration, and level.
  • PlayTimeHours remains similar across clusters, showing weak behavioural separation.

Business Insights from Hierarchical Clusters¶

  • Each cluster could benefit from a different focus:
    • Cluster 0: Could benefit from simpler experiences and help to support player progression.
    • Cluster 1: Shows occasional spending, so offers may encourage further purchases.
    • Cluster 2: Young, high-level players may respond well to advanced features to keep them engaged.

Overall Summary and Recommendations¶

  • Supervised models (SVM and Decision Tree) were used to predict player engagement levels, with the tuned SVM achieving the strongest recall for identifying at-risk Low-engagement players.

  • AUC-ROC results confirmed the SVM’s reliability in separating Low vs Not-Low players.

  • Unsupervised methods (K-Means and Agglomerative) revealed three clear behavioural groups: casual short-session players, long-session immersive players, and medium-session purchasers.

  • Supervised learning identifies who is at risk, while clustering explains how different player types behave.

  • Recommendation:

    • Use the tuned SVM model for early detection of low-engagement players.
    • Apply cluster insights to deliver targeted retention strategies and personalised in-game offers.