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

# Visualization 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

sns.set()  

df = pd.read_csv("online_gaming_behavior_dataset.csv")

Observation

  • The main Python libraries required for data handling, visualisation and model evaluation (pandas, numpy, matplotlib, seaborn and scikit-learn utilities) are imported.
  • The Kaggle dataset is loaded into a dataframe named game_df.
  • The head() output provides an initial view of the first records, confirming that demographic, behavioural and engagement-related variables are present and correctly loaded.

2. Exploratory Data Analysis (EDA)¶

First we explore the dataset to understand its structure, data types, and basic distributions before preparing it for modelling.

Observation

  • The top and bottom records show a consistent structure across the dataset.
  • Each row represents a single player, with demographic attributes, gameplay behaviour and an associated engagement level.
  • No empty or irregular rows are observed at the beginning or end of the dataset.
In [2]:
# Structure of the dataset: columns, data types, non-null counts
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 [3]:
# Check for missing values in each column
df.isnull().sum()
Out[3]:
PlayerID                     0
Age                          0
Gender                       0
Location                     0
GameGenre                    0
PlayTimeHours                0
InGamePurchases              0
GameDifficulty               0
SessionsPerWeek              0
AvgSessionDurationMinutes    0
PlayerLevel                  0
AchievementsUnlocked         0
EngagementLevel              0
dtype: int64
In [4]:
# Number of unique values in each column (to help spot ID-like fields)
df.nunique()
Out[4]:
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

Observation

  • The dataset contains 40,034 rows and 13 variables.
  • All columns have 0 missing values, so no imputation is required.
  • PlayerID and PlayTimeHours each have 40,034 unique values, acting like IDs / continuous values.
  • Categorical fields have few distinct values (e.g. Gender = 2, Location = 4, GameGenre = 5, GameDifficulty = 3, EngagementLevel = 3).
In [5]:
# Basic statistics for numerical features
df.describe()
Out[5]:
PlayerID Age PlayTimeHours InGamePurchases SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked
count 40034.000000 40034.000000 40034.000000 40034.000000 40034.000000 40034.000000 40034.000000 40034.000000
mean 29016.500000 31.992531 12.024365 0.200854 9.471774 94.792252 49.655568 24.526477
std 11556.964675 10.043227 6.914638 0.400644 5.763667 49.011375 28.588379 14.430726
min 9000.000000 15.000000 0.000115 0.000000 0.000000 10.000000 1.000000 0.000000
25% 19008.250000 23.000000 6.067501 0.000000 4.000000 52.000000 25.000000 12.000000
50% 29016.500000 32.000000 12.008002 0.000000 9.000000 95.000000 49.000000 25.000000
75% 39024.750000 41.000000 17.963831 0.000000 14.000000 137.000000 74.000000 37.000000
max 49033.000000 49.000000 23.999592 1.000000 19.000000 179.000000 99.000000 49.000000

Observation

  • Age ranges from 15 to 49 years, with an average around the early 30s.
  • Players typically play about 12 hours per week, with possible values between 0 and 24 hours.
  • Most players make 0 or 1 in-game purchase (mean ≈ 0.2, max = 1).
  • Sessions per week centre around 9–10 sessions, with most players between 4 and 14 sessions.
  • Average session duration is roughly 95 minutes, ranging from 10 to 179 minutes.
  • Player levels span 1 to 99 and achievements unlocked span 0 to 49, indicating a wide range of progression.
In [6]:
df['PlayerID'].nunique(), df.shape[0]
Out[6]:
(40034, 40034)

Observation

  • The number of unique PlayerID values equals the total number of rows.
  • This confirms that PlayerID functions purely as a unique identifier and does not contain predictive information about engagement.
  • As a result, PlayerID can be excluded from the feature set during model training to avoid introducing noise.
In [7]:
# Separate numerical and categorical columns
numeric_cols = df.select_dtypes(include=['int64', 'float64']).columns.tolist()
categorical_cols = df.select_dtypes(include=['object']).columns.tolist()

print("Numerical columns:", numeric_cols)
print("\nCategorical columns:", categorical_cols)
Numerical columns: ['PlayerID', 'Age', 'PlayTimeHours', 'InGamePurchases', 'SessionsPerWeek', 'AvgSessionDurationMinutes', 'PlayerLevel', 'AchievementsUnlocked']

Categorical columns: ['Gender', 'Location', 'GameGenre', 'GameDifficulty', 'EngagementLevel']

Observation

  • The numerical list contains PlayerID, Age, PlayTimeHours, InGamePurchases, SessionsPerWeek, AvgSessionDurationMinutes, PlayerLevel and AchievementsUnlocked.
  • The categorical list contains Gender, Location, GameGenre, GameDifficulty and EngagementLevel.
  • This separation clarifies which variables can be used directly in numerical models and which require encoding, and also highlights that PlayerID behaves as an identifier rather than a meaningful predictive feature.
In [8]:
# Distribution of the target variable (EngagementLevel)
print("Counts:")
print(df['EngagementLevel'].value_counts())

print("\nPercentage distribution:")
print(df['EngagementLevel'].value_counts(normalize=True) * 100)
Counts:
EngagementLevel
Medium    19374
High      10336
Low       10324
Name: count, dtype: int64

Percentage distribution:
EngagementLevel
Medium    48.393865
High      25.818055
Low       25.788080
Name: proportion, dtype: float64

Observation

  • EngagementLevel has three classes: Medium, High and Low.
  • Medium engagement: 19,374 players (≈ 48.4%).
  • High engagement: 10,336 players (≈ 25.8%).
  • Low engagement: 10,324 players (≈ 25.8%).
  • The target is moderately imbalanced, with Medium as the dominant class.
In [9]:
# Bar plot of EngagementLevel (similar to Attrition countplot in case study)
plt.figure(figsize=(6, 4))
sns.countplot(x='EngagementLevel', data=df)
plt.title('Distribution of Engagement Levels')
plt.xlabel('Engagement Level')
plt.ylabel('Number of Players')
plt.show()
No description has been provided for this image
  • The target variable EngagementLevel has three categories: Medium, High and Low.
  • Class counts are: Medium = 19,374, High = 10,336, Low = 10,324 players.
  • In percentage terms this corresponds to approximately 48.4% Medium, 25.8% High and 25.8% Low of the total dataset.
  • The distribution is therefore moderately imbalanced: Medium engagement is clearly the dominant class, while High and Low engagement are almost equal in size.
  • All three classes remain well represented, so multi-class classification is appropriate, but evaluation of the models should take this class distribution into account.
In [10]:
# Histograms for main numerical features
numeric_cols_for_plot = [
    'Age',
    'PlayTimeHours',
    'SessionsPerWeek',
    'AvgSessionDurationMinutes',
    'PlayerLevel',
    'AchievementsUnlocked'
]

df[numeric_cols_for_plot].hist(bins=20, figsize=(12, 8))
plt.show()
No description has been provided for this image

Observation¶

Histograms of numerical features

  • Age ranges roughly from 15–50 years and is broadly spread across young to middle-aged adults.
  • PlayTimeHours spans 0–24 hours per week, with players distributed across low, medium and high playtime.
  • SessionsPerWeek lies mostly between 4 and 14, indicating several logins per week for many players.
  • AvgSessionDurationMinutes is concentrated around medium-length sessions (about 40–120 minutes).
  • PlayerLevel covers levels 1–100, showing a mix of new and experienced players.
  • AchievementsUnlocked ranges from 0–50, with many players around 10–40 achievements (moderate progression).
In [11]:
numeric_for_corr = [
    'Age',
    'PlayTimeHours',
    'InGamePurchases',
    'SessionsPerWeek',
    'AvgSessionDurationMinutes',
    'PlayerLevel',
    'AchievementsUnlocked'
]

corr_matrix = df[numeric_for_corr].corr()

plt.figure(figsize=(8, 6))
sns.heatmap(corr_matrix, annot=True, cmap='YlOrRd', fmt=".2f")
plt.title('Correlation Matrix of Numerical Features')
plt.show()
No description has been provided for this image

Observation¶

Correlation matrix of numerical features

  • Off-diagonal correlations are very small (around −0.01 to 0.01), indicating weak linear relationships between features.
  • No pair of variables shows high correlation (e.g. ≥ 0.7), so multicollinearity is not a major concern.
  • Age is essentially uncorrelated with the gaming behaviour variables, and relationships such as PlayTimeHours–SessionsPerWeek and PlayerLevel–AchievementsUnlocked are present but very weak in linear terms.

3. Data Preparation¶

3.1 Copy data and remove identifier column¶

In [12]:
# Work on a copy of the original dataframe
data = df.copy()

# Drop the identifier column (similar to dropping EmployeeNumber in the case study)
data = data.drop(columns=['PlayerID'])

# Separate features (X) and target (y)
X = data.drop(columns=['EngagementLevel'])
y = data['EngagementLevel']

# Check the first few rows of X
X.head()
Out[12]:
Age Gender Location GameGenre PlayTimeHours InGamePurchases GameDifficulty SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked
0 43 Male Other Strategy 16.271119 0 Medium 6 108 79 25
1 29 Female USA Strategy 5.525961 0 Medium 5 144 11 10
2 22 Female USA Sports 8.223755 0 Easy 16 142 35 41
3 35 Male USA Action 5.265351 1 Easy 9 85 57 47
4 33 Male Europe Action 15.531945 0 Medium 2 131 95 37

3.2 Identify categorical and numerical columns for modelling¶

In [13]:
# 3.2 Identify categorical and numerical columns for modelling

categorical_cols = ['Gender', 'Location', 'GameGenre', 'GameDifficulty']
numeric_cols = [col for col in X.columns if col not in categorical_cols]

print("Categorical columns:", categorical_cols)
print("\nNumeric columns:", numeric_cols)
Categorical columns: ['Gender', 'Location', 'GameGenre', 'GameDifficulty']

Numeric columns: ['Age', 'PlayTimeHours', 'InGamePurchases', 'SessionsPerWeek', 'AvgSessionDurationMinutes', 'PlayerLevel', 'AchievementsUnlocked']

3.3 Convert categorical variables to dummy (one-hot) variables¶

In [14]:
# 3.3 Convert categorical variables to dummy variables

X_encoded = pd.get_dummies(X, columns=categorical_cols, drop_first=True)

X_encoded.head()
Out[14]:
Age PlayTimeHours InGamePurchases SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked Gender_Male Location_Europe Location_Other Location_USA GameGenre_RPG GameGenre_Simulation GameGenre_Sports GameGenre_Strategy GameDifficulty_Hard GameDifficulty_Medium
0 43 16.271119 0 6 108 79 25 True False True False False False False True False True
1 29 5.525961 0 5 144 11 10 False False False True False False False True False True
2 22 8.223755 0 16 142 35 41 False False False True False False True False False False
3 35 5.265351 1 9 85 57 47 True False False True False False False False False False
4 33 15.531945 0 2 131 95 37 True True False False False False False False False True

3.4 Split into training and test sets¶

In [15]:
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    X_encoded,
    y,
    test_size=0.2,
    random_state=42,
    stratify=y   # keep High/Medium/Low proportions similar in train and test
)

print("Train shape:", X_train.shape)
print("Test shape:", X_test.shape)
Train shape: (32027, 17)
Test shape: (8007, 17)

3.5 Scale features for SVM (Decision Tree will use the unscaled data)¶

In [16]:
from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

4.1 Decision Tree Classifier¶

4.1.1 Baseline Decision Tree¶

In this part a basic Decision Tree classifier is trained on the training set without any strong restrictions on depth or size.

  • The confusion matrix and classification report above show that the model correctly classifies a large proportion of players in all three engagement classes.
  • The reported accuracy on the test set gives an initial benchmark for later models.
  • Misclassifications mainly occur between High and Low engagement, which suggests that some behavioural patterns for these two groups are similar.
In [17]:
# 4.1 Decision Tree Classifier 

from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import confusion_matrix, classification_report, accuracy_score

# Create the Decision Tree model
dt_model = DecisionTreeClassifier(random_state=42)

# Train the model on the training data
dt_model.fit(X_train, y_train)

# Predict engagement level on the test data
y_pred_dt = dt_model.predict(X_test)

# Evaluate the model
print("Confusion Matrix (Decision Tree):")
print(confusion_matrix(y_test, y_pred_dt))

print("\nClassification Report (Decision Tree):")
print(classification_report(y_test, y_pred_dt))

dt_accuracy = accuracy_score(y_test, y_pred_dt)
print("Accuracy (Decision Tree): {:.3f}".format(dt_accuracy))
Confusion Matrix (Decision Tree):
[[1695  135  237]
 [ 152 1665  248]
 [ 252  273 3350]]

Classification Report (Decision Tree):
              precision    recall  f1-score   support

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

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

Accuracy (Decision Tree): 0.838

Observations¶

  • The baseline Decision Tree achieves an overall test accuracy of 0.838, correctly classifying most players across the three engagement levels.
  • Recall is around 0.82 for High, 0.81 for Low, and 0.86 for Medium engagement, indicating slightly better detection of Medium players.
  • The confusion matrix shows most misclassifications occur between High and Low engagement, suggesting that these two groups share similar behaviour patterns.
  • Misclassifications into the Medium class are less frequent, which implies that Medium-engagement players are more distinct in the feature space.

4.1.2 Tree visualisation¶

In [18]:
# Plot a simplified view of the Decision Tree (top levels only)
from sklearn import tree
import matplotlib.pyplot as plt

plt.figure(figsize=(18, 8))
tree.plot_tree(
    dt_model,
    feature_names=X_train.columns,
    class_names=dt_model.classes_,
    max_depth=3,      
    filled=True,
    rounded=True,
    fontsize=6
)
plt.title("Decision Tree for EngagementLevel (Top Levels)")
plt.show()
No description has been provided for this image

4.1.3 Decision Tree Hyperparameter Tuning (max_depth)¶

In [19]:
# 4.1.2 Decision Tree Hyperparameter Tuning (max_depth)

# Different depths to try (None means no limit – the default)
depth_values = [3, 5, 7, 9, None]
dt_results = []

for depth in depth_values:
    dt_temp = DecisionTreeClassifier(max_depth=depth, random_state=42)
    dt_temp.fit(X_train, y_train)
    y_pred_temp = dt_temp.predict(X_test)
    acc = accuracy_score(y_test, y_pred_temp)
    dt_results.append((depth, acc))
    print(f"max_depth={depth}, test accuracy = {acc:.3f}")

# Pick the best depth based on test accuracy
best_depth, best_acc = max(dt_results, key=lambda x: x[1])
print("\nBest max_depth:", best_depth)
print("Best test accuracy:", round(best_acc, 3))
max_depth=3, test accuracy = 0.796
max_depth=5, test accuracy = 0.849
max_depth=7, test accuracy = 0.883
max_depth=9, test accuracy = 0.896
max_depth=None, test accuracy = 0.838

Best max_depth: 9
Best test accuracy: 0.896

Observations¶

  • Tuning the max_depth hyperparameter from 3 to 9 increases test accuracy from 0.796 to 0.896, after which performance drops when no depth limit is used.
  • The best-performing model uses max_depth = 9, giving a test accuracy of approximately 0.896, which is substantially higher than the baseline tree (0.838).
  • Very shallow trees (e.g. depth 3) underfit the data, whereas an unlimited depth starts to overfit, confirming that a moderately deep tree provides the best generalisation.
In [20]:
def metrics_score(y_true, y_pred, title=""):
    """
    Helper function to print classification report and plot confusion matrix.
    This is similar to the case study function but adapted to multi-class.
    """
    print(classification_report(y_true, y_pred))
    print("Accuracy:", accuracy_score(y_true, y_pred))

    cm = confusion_matrix(y_true, y_pred)

    plt.figure(figsize=(5, 4))
    sns.heatmap(cm, annot=True, fmt=".0f", cmap="rocket_r")
    plt.xlabel("Predicted")
    plt.ylabel("Actual")
    if title:
        plt.title(f"Confusion Matrix – {title}")
    else:
        plt.title("Confusion Matrix")
    plt.show()

4.2 SVM Classifier (Linear Kernel)¶

Here an SVM with a linear kernel is trained on the scaled features. This is similar to the linear SVM in the Employee Attrition case study, but adapted to predict the multi-class EngagementLevel (High, Medium, Low).

In [21]:
# Fitting linear SVM 
svm_linear = SVC(kernel='linear', random_state=42)

# Fit the model on the scaled training data
model_linear = svm_linear.fit(X_train_scaled, y_train)

# Performance on the training data 
y_pred_train_svm = model_linear.predict(X_train_scaled)
print("Training performance (SVM - linear):")
metrics_score(y_train, y_pred_train_svm, title="SVM Linear - Train")

#  Performance on the test data 
y_pred_test_svm = model_linear.predict(X_test_scaled)
print("Test performance (SVM - linear):")
metrics_score(y_test, y_pred_test_svm, title="SVM Linear - Test")

# Save accuracy for later comparison
svm_linear_acc = accuracy_score(y_test, y_pred_test_svm)
print("Test Accuracy (SVM - linear): {:.3f}".format(svm_linear_acc))
Training performance (SVM - linear):
              precision    recall  f1-score   support

        High       0.89      0.85      0.87      8269
         Low       0.79      0.71      0.75      8259
      Medium       0.81      0.87      0.84     15499

    accuracy                           0.83     32027
   macro avg       0.83      0.81      0.82     32027
weighted avg       0.83      0.83      0.83     32027

Accuracy: 0.8261154650763418
No description has been provided for this image
Test performance (SVM - linear):
              precision    recall  f1-score   support

        High       0.88      0.84      0.86      2067
         Low       0.78      0.70      0.74      2065
      Medium       0.81      0.87      0.84      3875

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

Accuracy: 0.818658673660547
No description has been provided for this image
Test Accuracy (SVM - linear): 0.819

Observations¶

  • The linear SVM achieves a test accuracy of about 0.819, slightly lower than both the baseline and tuned Decision Trees.
  • Training accuracy (≈0.826) and test accuracy (≈0.819) are very similar, suggesting limited overfitting and a relatively stable linear decision boundary.
  • Class-wise performance shows stronger recall for the Medium class (≈0.87) than for High (≈0.84) and Low (≈0.70), indicating that linear separation is more effective for Medium-engagement players.

4.3 SVM Classifier – RBF Kernel (Tuned Model)¶

In this section we train an SVM with an RBF kernel. This allows for non-linear decision boundaries, similar to the tuned RBF SVM used in the Employee Attrition case study. We compare its performance with the linear SVM and the Decision Tree.

In [22]:
# 4.3 SVM with RBF kernel (tuned model)

# Here we choose C and gamma manually instead of using complex grid search.
# This keeps the code simple but still shows hyperparameter tuning.

svm_rbf = SVC(
    kernel='rbf',
    C=2.0,            # NEW: C > 1 gives a slightly more flexible boundary
    gamma='scale',    # default gamma; could also try a custom value
    random_state=42
)

# Fit the model on the scaled training data
model_rbf = svm_rbf.fit(X_train_scaled, y_train)

# Performance on the training data 
y_pred_train_rbf = model_rbf.predict(X_train_scaled)
print("Training performance (SVM - RBF):")
metrics_score(y_train, y_pred_train_rbf, title="SVM RBF - Train")

# Performance on the test data 
y_pred_test_rbf = model_rbf.predict(X_test_scaled)
print("Test performance (SVM - RBF):")
metrics_score(y_test, y_pred_test_rbf, title="SVM RBF - Test")

# Save accuracy for comparison
svm_rbf_acc = accuracy_score(y_test, y_pred_test_rbf)
print("Test Accuracy (SVM - RBF): {:.3f}".format(svm_rbf_acc))
Training performance (SVM - RBF):
              precision    recall  f1-score   support

        High       0.93      0.88      0.91      8269
         Low       0.92      0.86      0.89      8259
      Medium       0.90      0.95      0.93     15499

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

Accuracy: 0.9125737658850345
No description has been provided for this image
Test performance (SVM - RBF):
              precision    recall  f1-score   support

        High       0.91      0.84      0.88      2067
         Low       0.88      0.83      0.86      2065
      Medium       0.87      0.93      0.90      3875

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

Accuracy: 0.8814787061321344
No description has been provided for this image
Test Accuracy (SVM - RBF): 0.881

Observations¶

  • The tuned RBF SVM (with C = 2.0) achieves a test accuracy of approximately 0.881, higher than the linear SVM (0.819) and the baseline Decision Tree (0.838), but slightly lower than the tuned Decision Tree (0.896).
  • Training accuracy (≈0.913) is higher than test accuracy (≈0.881), which indicates some overfitting, but the gap is not extreme.
  • Precision and recall are relatively balanced across all three classes (High, Low, Medium), showing that the non-linear kernel captures additional structure in the gaming behaviour data compared to the linear model.
In [23]:
# Re-fit the tuned Decision Tree using the best depth you already found
dt_tuned = DecisionTreeClassifier(max_depth=best_depth, random_state=42)
dt_tuned.fit(X_train, y_train)
y_pred_dt_tuned = dt_tuned.predict(X_test)

#  Use the tuned RBF SVM you already trained (svm_rbf)
y_pred_svm_tuned = svm_rbf.predict(X_test_scaled)

#  Build comparison table (Decision Tree vs SVM, tuned models only)
model_compare = {
    "Decision Tree (tuned)": {
        "accuracy":           accuracy_score(y_test, y_pred_dt_tuned),
        "precision_weighted": precision_score(y_test, y_pred_dt_tuned, average="weighted"),
        "recall_weighted":    recall_score(y_test, y_pred_dt_tuned,  average="weighted"),
        "f1_weighted":        f1_score(y_test, y_pred_dt_tuned,      average="weighted"),
    },
    "SVM (tuned)": {
        "accuracy":           accuracy_score(y_test, y_pred_svm_tuned),
        "precision_weighted": precision_score(y_test, y_pred_svm_tuned, average="weighted"),
        "recall_weighted":    recall_score(y_test, y_pred_svm_tuned,  average="weighted"),
        "f1_weighted":        f1_score(y_test, y_pred_svm_tuned,      average="weighted"),
    }
}

comparison_df = pd.DataFrame(model_compare).T
print(comparison_df)
                       accuracy  precision_weighted  recall_weighted  \
Decision Tree (tuned)  0.895966            0.896118         0.895966   
SVM (tuned)            0.881479            0.882258         0.881479   

                       f1_weighted  
Decision Tree (tuned)     0.895549  
SVM (tuned)               0.881002  

Observations¶

  • The tuned Decision Tree reaches the highest test accuracy (≈0.896), followed by the tuned RBF SVM (≈0.881), indicating that both tuned models improve substantially over their simpler baselines.
  • Weighted precision, recall, and F1-score are slightly higher for the tuned Decision Tree, suggesting marginally better overall classification of all engagement levels.
  • The tuned RBF SVM provides smoother, non-linear decision boundaries and strong performance, but the Decision Tree retains a small accuracy advantage while also being more interpretable for explaining which features drive engagement.

5.0 Clustering - K-Means¶

5.1 Age vs PlayTimeHours (Cluster_Age_Play)¶

In [24]:
# Quick look at Age vs PlayTimeHours
plt.scatter(df['Age'], df['PlayTimeHours'])
plt.xlabel('Age')
plt.ylabel('PlayTimeHours')
plt.title('Players: Age vs Play Time')
plt.show()
No description has been provided for this image
In [25]:
# K-Means with 3 clusters on Age and PlayTmeHours
km_sample = KMeans(n_clusters=3, random_state=42)
km_sample.fit(df[['Age', 'PlayTimeHours']])

# Get labels and attach to dataframe
labels_sample = km_sample.labels_
df['Cluster_Age_Play'] = labels_sample

# Plot clusters
sns.scatterplot(
    x=df['Age'],
    y=df['PlayTimeHours'],
    hue=df['Cluster_Age_Play'],
    palette='Set1'
)
plt.xlabel('Age')
plt.ylabel('PlayTimeHours')
plt.title('K-Means Clusters (Age vs PlayTimeHours)')
plt.show()
No description has been provided for this image

Observations

  • Three clusters were identified on the Age–PlayTimeHours space using K-Means with k = 3.
  • Cluster 0: mostly younger players (mid-teens to mid-20s) with high play time (around 18–24 hours) → very active gamers.
  • Cluster 1: players across a wide age range (mid-20s to late-40s) with medium play time → mixed, moderate-activity group.
  • Cluster 2: mainly older players (early-30s to 50) with lower play time → more casual or time-constrained players.
In [26]:
# K-Means with 3 clusters on SessionsPerWeek and AchievementsUnlocked
km_sample2 = KMeans(n_clusters=3, random_state=42)
km_sample2.fit(df[['SessionsPerWeek', 'AchievementsUnlocked']])

labels_sample2 = km_sample2.labels_
df['Cluster_Sessions_Ach'] = labels_sample2

sns.scatterplot(
    x=df['SessionsPerWeek'],
    y=df['AchievementsUnlocked'],
    hue=df['Cluster_Sessions_Ach'],
    palette='Set1'
)
plt.xlabel('SessionsPerWeek')
plt.ylabel('AchievementsUnlocked')
plt.title('K-Means Clusters (Sessions vs Achievements)')
plt.show()
No description has been provided for this image

Observations

  • K-Means with k = 3 on SessionsPerWeek and AchievementsUnlocked produced three clear behavioural clusters.
  • Cluster 0: high sessions per week and high achievements → highly engaged players who play frequently and progress quickly.
  • Cluster 1: moderate sessions and medium achievements → steady but not extreme engagement.
  • Cluster 2: low sessions and low achievements → low-engagement players who log in rarely and unlock fewer rewards.
In [27]:
wcss = []
K = range(1, 9)

for i in K:
    kmeanModel = KMeans(
        n_clusters=i,
        init='k-means++',
        max_iter=300,
        n_init=10,
        random_state=42
    )
    kmeanModel.fit(df[['Age', 'PlayTimeHours']])
    wcss.append(kmeanModel.inertia_)   # inertia_ == WCSS

plt.plot(range(1, 9), wcss, marker='o')
plt.xlabel('Number of clusters')
plt.ylabel('WCSS')
plt.title('Elbow Method (Age vs PlayTimeHours)')
plt.show()
No description has been provided for this image

Observations

  • The WCSS (within-cluster sum of squares) curve drops sharply up to k ≈ 3, then flattens, indicating diminishing returns beyond 3–4 clusters.
  • Silhouette scores for Age–PlayTimeHours are highest at k = 2 (≈ 0.431) and remain reasonable at k = 3 (≈ 0.394), then decrease slightly for higher k values.
  • k = 3 is used as a compromise: it still has a good silhouette score and gives more interpretable segments (young-high play, mixed-medium play, older-low play).

What is the recommended number of clusters?

From the elbow plot, the curve starts to bend around k = X (you fill this in), so we can choose k = X as a reasonable number of clusters for this feature pair.

In [28]:
from sklearn.metrics import silhouette_score

for k in range(2, 9):
    kmeans_temp = KMeans(n_clusters=k, random_state=42, n_init=10)
    labels_temp = kmeans_temp.fit_predict(df[['Age', 'PlayTimeHours']])
    sil = silhouette_score(df[['Age', 'PlayTimeHours']], labels_temp)
    print(f"k={k}, silhouette score = {sil:.3f}")
k=2, silhouette score = 0.431
k=3, silhouette score = 0.394
k=4, silhouette score = 0.383
k=5, silhouette score = 0.388
k=6, silhouette score = 0.382
k=7, silhouette score = 0.367
k=8, silhouette score = 0.382
In [29]:
# Compare clusters (Sessions vs Achievements) with EngagementLevel
pd.crosstab(df['Cluster_Sessions_Ach'], df['EngagementLevel'])
Out[29]:
EngagementLevel High Low Medium
Cluster_Sessions_Ach
0 3124 3748 5880
1 3666 2971 6983
2 3546 3605 6511

Observations

  • The crosstab between Cluster_Sessions_Ach and EngagementLevel shows Medium engagement dominating all clusters, reflecting its overall higher proportion in the dataset.
  • Cluster 0 (high sessions, high achievements) contains many High and Medium engagement players (e.g. 3124 High, 5880 Medium), meaning very active behaviour is strongly linked with higher engagement labels.
  • Cluster 1 has the largest count of Medium players (6983) and a substantial number of High players (3666), suggesting that moderate activity often corresponds to Medium engagement.
  • Cluster 2 has similar numbers of High and Low players (3546 High, 3605 Low) plus many Medium players (6511), indicating that low-activity behaviour appears across all engagement levels.
  • Overall, K-Means reveals meaningful behavioural segments (high, medium, low activity), but engagement labels are still mixed within each cluster, so clusters are helpful for understanding player types rather than perfectly predicting engagement level.
In [ ]: