1. Imports¶

In [1]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

from sklearn.model_selection import train_test_split, GridSearchCV, StratifiedShuffleSplit
from sklearn.preprocessing import LabelEncoder, StandardScaler, label_binarize
from sklearn.metrics import (
    accuracy_score, precision_score, recall_score, f1_score,
    classification_report, confusion_matrix, ConfusionMatrixDisplay,
    roc_auc_score, roc_curve, auc
)

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 warnings
warnings.filterwarnings("ignore")

sns.set(style="whitegrid")
RANDOM_STATE = 42

2. Load dataset¶

In [2]:
# Load the dataset (Excel)
DATAFILE = "AIdataset.xlsx"

df = pd.read_excel(DATAFILE)
print("Rows, cols:", df.shape)
df.head()
Rows, cols: (40034, 13)
Out[2]:
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

3. Exploratory Data Analysis (EDA)¶

In [3]:
print(df.info())
print("\nMissing values per column:")
print(df.isna().sum())

print("\nTarget distribution:")
print(df["EngagementLevel"].value_counts())

plt.figure(figsize=(6,4))
sns.countplot(x=df["EngagementLevel"], order=df["EngagementLevel"].value_counts().index)
plt.title("Engagement Level Distribution")
plt.tight_layout()
plt.show()
<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
None

Missing values per column:
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

Target distribution:
EngagementLevel
Medium    19374
High      10336
Low       10324
Name: count, dtype: int64
No description has been provided for this image
In [4]:
# Basic numeric summary and correlation
numeric_cols = df.select_dtypes(include=["int64", "float64"]).columns.tolist()
numeric_cols = [c for c in numeric_cols if c not in ["PlayerID"]]  # exclude ID from analysis

plt.figure(figsize=(9,6))
corr = df[numeric_cols].corr()
sns.heatmap(corr, annot=False, cmap="coolwarm")
plt.title("Correlation Heatmap (Numeric Features)")
plt.tight_layout()
plt.show()
No description has been provided for this image

4. Preprocessing¶

In [5]:
# Encode target
le = LabelEncoder()
y = le.fit_transform(df["EngagementLevel"])  # class order depends on data; keep le for decoding
class_names = le.classes_
print("Classes:", class_names)

# Prepare features
X_raw = df.drop(columns=["EngagementLevel"])

# One-hot encode categorical features
X = pd.get_dummies(X_raw, drop_first=True)

# Train/test split (stratified)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=RANDOM_STATE, stratify=y
)

# Scale features (important for SVM and K-Means)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

print("X_train:", X_train.shape, "X_test:", X_test.shape)
Classes: ['High' 'Low' 'Medium']
X_train: (32027, 18) X_test: (8007, 18)

5. Supervised Learning: Decision Tree¶

In [6]:
# 5.1 Baseline Decision Tree
dt = DecisionTreeClassifier(random_state=RANDOM_STATE)
dt.fit(X_train_scaled, y_train)
y_pred_dt = dt.predict(X_test_scaled)

print("Decision Tree (Baseline)")
print("Accuracy:", accuracy_score(y_test, y_pred_dt))
print(classification_report(y_test, y_pred_dt, target_names=class_names, zero_division=0))
Decision Tree (Baseline)
Accuracy: 0.8347695766204571
              precision    recall  f1-score   support

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

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

In [7]:
# 5.2 Tuned Decision Tree (GridSearchCV)
param_grid_dt = {
    "criterion": ["gini", "entropy"],
    "max_depth": [5, 10, None],
    "min_samples_split": [2, 5, 10],
    "min_samples_leaf": [1, 5, 10]
}

dt_grid = GridSearchCV(
    DecisionTreeClassifier(random_state=RANDOM_STATE),
    param_grid_dt,
    cv=5,
    scoring="f1_macro",
    n_jobs=-1
)
dt_grid.fit(X_train_scaled, y_train)

best_dt = dt_grid.best_estimator_
y_pred_dt_tuned = best_dt.predict(X_test_scaled)

print("Best DT params:", dt_grid.best_params_)
print("Decision Tree (Tuned)")
print("Accuracy:", accuracy_score(y_test, y_pred_dt_tuned))
print(classification_report(y_test, y_pred_dt_tuned, target_names=class_names, zero_division=0))
Best DT params: {'criterion': 'entropy', 'max_depth': 10, 'min_samples_leaf': 10, 'min_samples_split': 2}
Decision Tree (Tuned)
Accuracy: 0.9028350193580617
              precision    recall  f1-score   support

        High       0.91      0.88      0.90      2067
         Low       0.89      0.87      0.88      2065
      Medium       0.91      0.93      0.92      3875

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

In [8]:
# 5.3 Decision Tree confusion matrix 
plt.figure(figsize=(6,5))
ConfusionMatrixDisplay.from_predictions(y_test, y_pred_dt_tuned, display_labels=class_names, cmap="Blues")
plt.title("Confusion Matrix: Tuned Decision Tree")
plt.tight_layout()
plt.show()
<Figure size 600x500 with 0 Axes>
No description has been provided for this image

6. Supervised Learning: Support Vector Machine (SVM)¶

In [9]:
# 6.1 Baseline SVM (RBF)
svm = SVC(kernel="rbf", probability=True, random_state=RANDOM_STATE)
svm.fit(X_train_scaled, y_train)
y_pred_svm = svm.predict(X_test_scaled)

print("SVM (Baseline)")
print("Accuracy:", accuracy_score(y_test, y_pred_svm))
print(classification_report(y_test, y_pred_svm, target_names=class_names, zero_division=0))
SVM (Baseline)
Accuracy: 0.8804795803671788
              precision    recall  f1-score   support

        High       0.91      0.84      0.88      2067
         Low       0.88      0.83      0.85      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

In [10]:
# 6.2 Tuned SVM 
sss = StratifiedShuffleSplit(n_splits=1, test_size=0.8, random_state=RANDOM_STATE)
tune_idx, _ = next(sss.split(X_train_scaled, y_train))
X_tune = X_train_scaled[tune_idx]
y_tune = y_train[tune_idx]

param_grid_svm = {
    "C": [0.1, 1, 10],
    "gamma": ["scale", 0.01, 0.1],
    "kernel": ["rbf"]
}

svm_grid = GridSearchCV(
    SVC(probability=True, random_state=RANDOM_STATE),
    param_grid_svm,
    cv=3,
    scoring="f1_macro",
    n_jobs=-1
)
svm_grid.fit(X_tune, y_tune)

best_svm = svm_grid.best_estimator_
best_svm.fit(X_train_scaled, y_train)
y_pred_svm_tuned = best_svm.predict(X_test_scaled)

print("Best SVM params:", svm_grid.best_params_)
print("SVM (Tuned)")
print("Accuracy:", accuracy_score(y_test, y_pred_svm_tuned))
print(classification_report(y_test, y_pred_svm_tuned, target_names=class_names, zero_division=0))
Best SVM params: {'C': 10, 'gamma': 0.01, 'kernel': 'rbf'}
SVM (Tuned)
Accuracy: 0.8867241163981516
              precision    recall  f1-score   support

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

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

In [11]:
# 6.3 SVM confusion matrix
plt.figure(figsize=(6,5))
ConfusionMatrixDisplay.from_predictions(y_test, y_pred_svm_tuned, display_labels=class_names, cmap="Oranges")
plt.title("Confusion Matrix: Tuned SVM")
plt.tight_layout()
plt.show()
<Figure size 600x500 with 0 Axes>
No description has been provided for this image

7. Model Evaluation and Comparison¶

In [12]:
# 7.1 Performance comparison table (macro averaged metrics)
comparison = pd.DataFrame({
    "Model": ["Decision Tree (Tuned)", "SVM (Tuned)"],
    "Accuracy": [
        accuracy_score(y_test, y_pred_dt_tuned),
        accuracy_score(y_test, y_pred_svm_tuned)
    ],
    "Precision (macro)": [
        precision_score(y_test, y_pred_dt_tuned, average="macro", zero_division=0),
        precision_score(y_test, y_pred_svm_tuned, average="macro", zero_division=0)
    ],
    "Recall (macro)": [
        recall_score(y_test, y_pred_dt_tuned, average="macro", zero_division=0),
        recall_score(y_test, y_pred_svm_tuned, average="macro", zero_division=0)
    ],
    "F1-score (macro)": [
        f1_score(y_test, y_pred_dt_tuned, average="macro", zero_division=0),
        f1_score(y_test, y_pred_svm_tuned, average="macro", zero_division=0)
    ]
}).round(4)

comparison
Out[12]:
Model Accuracy Precision (macro) Recall (macro) F1-score (macro)
0 Decision Tree (Tuned) 0.9028 0.9019 0.8947 0.8982
1 SVM (Tuned) 0.8867 0.8919 0.8729 0.8814

8. ROC-AUC Evaluation (multi-class OvR)¶

In [13]:
# ROC-AUC requires probability estimates
classes = np.unique(y_test)
y_test_bin = label_binarize(y_test, classes=classes)

dt_probs = best_dt.predict_proba(X_test_scaled)
svm_probs = best_svm.predict_proba(X_test_scaled)

dt_auc = roc_auc_score(y_test_bin, dt_probs, multi_class="ovr", average="macro")
svm_auc = roc_auc_score(y_test_bin, svm_probs, multi_class="ovr", average="macro")

print("Decision Tree ROC-AUC (macro, OvR):", round(dt_auc, 4))
print("SVM ROC-AUC (macro, OvR):", round(svm_auc, 4))
Decision Tree ROC-AUC (macro, OvR): 0.9344
SVM ROC-AUC (macro, OvR): 0.9401
In [14]:
# ROC curves per class 
def plot_multiclass_roc(y_true_bin, y_prob, title):
    plt.figure(figsize=(7,6))
    for i, cls in enumerate(classes):
        fpr, tpr, _ = roc_curve(y_true_bin[:, i], y_prob[:, i])
        roc_auc = auc(fpr, tpr)
        plt.plot(fpr, tpr, label=f"Class {cls} (AUC={roc_auc:.3f})")
    plt.plot([0,1], [0,1], "k--", label="Random")
    plt.xlabel("False Positive Rate")
    plt.ylabel("True Positive Rate")
    plt.title(title)
    plt.legend()
    plt.tight_layout()
    plt.show()

plot_multiclass_roc(y_test_bin, dt_probs, "Decision Tree ROC Curves (OvR)")
plot_multiclass_roc(y_test_bin, svm_probs, "SVM ROC Curves (OvR)")
No description has been provided for this image
No description has been provided for this image

9. Unsupervised Learning: K-Means Clustering¶

In [15]:
# Use full feature space (scaled) for clustering (no labels used)
X_full_scaled = scaler.transform(X)  # scale full dataset using training-fitted scaler

# Elbow + silhouette on a sample for speed
np.random.seed(RANDOM_STATE)
sample_size = min(8000, X_full_scaled.shape[0])
sample_idx = np.random.choice(X_full_scaled.shape[0], size=sample_size, replace=False)
X_sample = X_full_scaled[sample_idx]

k_values = range(2, 11)
inertias, sils = [], []

for k in k_values:
    km = KMeans(n_clusters=k, random_state=RANDOM_STATE, n_init="auto")
    labels = km.fit_predict(X_sample)
    inertias.append(km.inertia_)
    sils.append(silhouette_score(X_sample, labels))

plt.figure(figsize=(6,4))
plt.plot(list(k_values), inertias, marker="o")
plt.title("K-Means Elbow (sample)")
plt.xlabel("k")
plt.ylabel("Inertia")
plt.tight_layout()
plt.show()

plt.figure(figsize=(6,4))
plt.plot(list(k_values), sils, marker="o")
plt.title("K-Means Silhouette (sample)")
plt.xlabel("k")
plt.ylabel("Silhouette score")
plt.tight_layout()
plt.show()

best_k = list(k_values)[int(np.argmax(sils))]
print("Best k by silhouette (sample):", best_k)
No description has been provided for this image
No description has been provided for this image
Best k by silhouette (sample): 4
In [16]:
# 9.1 Fit final K-Means on full dataset using chosen k
kmeans_full = KMeans(n_clusters=best_k, random_state=RANDOM_STATE, n_init="auto")
clusters_full = kmeans_full.fit_predict(X_full_scaled)

df_clusters = df.copy()
df_clusters["Cluster"] = clusters_full

df_clusters[["EngagementLevel", "Cluster"]].head()
Out[16]:
EngagementLevel Cluster
0 Medium 1
1 Medium 1
2 High 0
3 Medium 0
4 Medium 0

10. Cluster Interpretation and Visualisation¶

In [17]:
# Cluster vs engagement distribution (percent)
cluster_mix_full = pd.crosstab(
    df_clusters["Cluster"], df_clusters["EngagementLevel"], normalize="index"
) * 100

cluster_mix_full = cluster_mix_full.round(1)
cluster_mix_full
Out[17]:
EngagementLevel High Low Medium
Cluster
0 25.8 25.7 48.5
1 26.2 25.0 48.8
2 25.0 26.4 48.6
3 26.4 26.2 47.5
In [18]:
ax = cluster_mix_full.plot(kind="bar", stacked=True, figsize=(10,6))
plt.title("Engagement Level Distribution by Cluster (Full Dataset)")
plt.xlabel("Cluster")
plt.ylabel("Percentage of players")
plt.legend(title="Engagement Level", bbox_to_anchor=(1.02, 1))
plt.tight_layout()
plt.show()
No description has been provided for this image
In [19]:
# Profile clusters using key behavioural features
key_features = [
    "PlayTimeHours", "InGamePurchases", "SessionsPerWeek",
    "AvgSessionDurationMinutes", "PlayerLevel", "AchievementsUnlocked"
]

df_clusters.groupby("Cluster")[key_features].mean().round(2)
Out[19]:
PlayTimeHours InGamePurchases SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked
Cluster
0 12.07 0.20 9.49 94.91 49.85 24.49
1 12.08 0.21 9.54 95.64 49.90 24.58
2 12.01 0.20 9.41 93.74 49.43 24.52
3 11.90 0.20 9.44 94.74 49.25 24.55
In [20]:
# PCA 2D projection for cluster visualisation
pca = PCA(n_components=2, random_state=RANDOM_STATE)
X_pca = pca.fit_transform(X_full_scaled)

plt.figure(figsize=(7,6))
plt.scatter(X_pca[:, 0], X_pca[:, 1], c=clusters_full, s=10)
plt.title("K-Means Clusters (PCA 2D Projection)")
plt.xlabel("PC1")
plt.ylabel("PC2")
plt.tight_layout()
plt.show()

print("PCA explained variance (%):", (pca.explained_variance_ratio_ * 100).round(1))
No description has been provided for this image
PCA explained variance (%): [8.5 7.4]

11. Feature Importance (Decision Tree)¶

In [21]:
# Feature importance from tuned Decision Tree
importance = pd.Series(best_dt.feature_importances_, index=X_train.columns).sort_values(ascending=False)
top10 = importance.head(10)

plt.figure(figsize=(8,5))
top10.sort_values().plot(kind="barh")
plt.title("Top 10 Feature Importances (Tuned Decision Tree)")
plt.xlabel("Importance")
plt.tight_layout()
plt.show()

top10
No description has been provided for this image
Out[21]:
SessionsPerWeek              0.446113
AvgSessionDurationMinutes    0.435923
AchievementsUnlocked         0.042517
PlayerLevel                  0.039862
PlayerID                     0.009335
PlayTimeHours                0.008522
Age                          0.008065
Gender_Male                  0.001822
GameDifficulty_Medium        0.001729
GameGenre_Simulation         0.001262
dtype: float64