# 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.
# 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
# Check for missing values in each column
df.isnull().sum()
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
# Number of unique values in each column (to help spot ID-like fields)
df.nunique()
PlayerID 40034 Age 35 Gender 2 Location 4 GameGenre 5 PlayTimeHours 40034 InGamePurchases 2 GameDifficulty 3 SessionsPerWeek 20 AvgSessionDurationMinutes 170 PlayerLevel 99 AchievementsUnlocked 50 EngagementLevel 3 dtype: int64
Observation
- The dataset contains 40,034 rows and 13 variables.
- All columns have 0 missing values, so no imputation is required.
PlayerIDandPlayTimeHourseach 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).
# Basic statistics for numerical features
df.describe()
| 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.
df['PlayerID'].nunique(), df.shape[0]
(40034, 40034)
Observation
- The number of unique
PlayerIDvalues equals the total number of rows. - This confirms that
PlayerIDfunctions purely as a unique identifier and does not contain predictive information about engagement. - As a result,
PlayerIDcan be excluded from the feature set during model training to avoid introducing noise.
# 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,PlayerLevelandAchievementsUnlocked. - The categorical list contains
Gender,Location,GameGenre,GameDifficultyandEngagementLevel. - This separation clarifies which variables can be used directly in numerical
models and which require encoding, and also highlights that
PlayerIDbehaves as an identifier rather than a meaningful predictive feature.
# 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
EngagementLevelhas 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.
# 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()
- The target variable
EngagementLevelhas 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.
# 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()
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).
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()
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¶
# 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()
| 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¶
# 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¶
# 3.3 Convert categorical variables to dummy variables
X_encoded = pd.get_dummies(X, columns=categorical_cols, drop_first=True)
X_encoded.head()
| 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¶
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)¶
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.
# 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¶
# 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()
4.1.3 Decision Tree Hyperparameter Tuning (max_depth)¶
# 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_depthhyperparameter 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.
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).
# 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
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
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.
# 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
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
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.
# 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)¶
# 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()
# 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()
Observations
- Three clusters were identified on the
Age–PlayTimeHoursspace using K-Means withk = 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.
# 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()
Observations
- K-Means with
k = 3onSessionsPerWeekandAchievementsUnlockedproduced 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.
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()
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–PlayTimeHoursare highest at k = 2 (≈ 0.431) and remain reasonable at k = 3 (≈ 0.394), then decrease slightly for higher k values. k = 3is 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.
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
# Compare clusters (Sessions vs Achievements) with EngagementLevel
pd.crosstab(df['Cluster_Sessions_Ach'], df['EngagementLevel'])
| 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_AchandEngagementLevelshows 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.