import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.cluster import KMeans
from sklearn.preprocessing import LabelEncoder, StandardScaler
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score, classification_report, silhouette_score, roc_auc_score, confusion_matrix, ConfusionMatrixDisplay
from sklearn.svm import SVC
import matplotlib.pyplot as plt
import seaborn as sns
data=pd.read_csv('online_gaming_behavior_dataset.csv')
print(data)
PlayerID Age Gender Location GameGenre PlayTimeHours \
0 9000 43 Male Other Strategy 16.271119
1 9001 29 Female USA Strategy 5.525961
2 9002 22 Female USA Sports 8.223755
3 9003 35 Male USA Action 5.265351
4 9004 33 Male Europe Action 15.531945
... ... ... ... ... ... ...
40029 49029 32 Male USA Strategy 20.619662
40030 49030 44 Female Other Simulation 13.539280
40031 49031 15 Female USA RPG 0.240057
40032 49032 34 Male USA Sports 14.017818
40033 49033 19 Male USA Sports 10.083804
InGamePurchases GameDifficulty SessionsPerWeek \
0 0 Medium 6
1 0 Medium 5
2 0 Easy 16
3 1 Easy 9
4 0 Medium 2
... ... ... ...
40029 0 Easy 4
40030 0 Hard 19
40031 1 Easy 10
40032 1 Medium 3
40033 0 Easy 13
AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked \
0 108 79 25
1 144 11 10
2 142 35 41
3 85 57 47
4 131 95 37
... ... ... ...
40029 75 85 14
40030 114 71 27
40031 176 29 1
40032 128 70 10
40033 84 72 39
EngagementLevel
0 Medium
1 Medium
2 High
3 Medium
4 Medium
... ...
40029 Medium
40030 High
40031 High
40032 Medium
40033 Medium
[40034 rows x 13 columns]
data.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
data.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 |
data.head()
| 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 |
data.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
Data Overview Intepereation
- The dataset contains 40,034 player records with 13 features - one of which is a PlayerID used as an identifier.
- There are no missing values
Features
- 7 numeric columns
- 5 categorial column
Data Characteristics
- Average age is 32
- Average weekly playtime hours is 12
- In game purchases are very rare
- Average sessions per week is 9 - lasting 94.5 mins on average
- Players of all levels play
plt.hist(data['Age'], bins=20)
plt.title("Age Distribution")
plt.xlabel("Age")
plt.ylabel("Frequency")
plt.show()
The above histogram shows that age is distributed failry even across the 15-50 range. There aren't any spikes in the data for any particular age group. Most groups have a similar number of gamers. This spread is useful for modelling as there isnt a group that dominates the rest, therefore, there isn't an imbalance.
data['EngagementLevel'].value_counts().plot(kind='bar')
plt.title("Engagement Level Distribution")
plt.xlabel("Engagement Level")
plt.ylabel("Number of Players")
plt.show()
data['EngagementLevel'].value_counts()
EngagementLevel Medium 19374 High 10336 Low 10324 Name: count, dtype: int64
In terms of engagement, the data is farily balanced. Medium is the largest group with 19374 players, with high and low showing an almost equal amount of players
plt.figure(figsize=(6,4))
data['Gender'].value_counts().plot(kind='bar')
plt.title("Gender Distribution")
plt.xlabel("Gender")
plt.ylabel("Number of Players")
plt.show()
data['Gender'].value_counts()
Gender Male 23959 Female 16075 Name: count, dtype: int64
The gender split shows that there are more male players than there are female. We need to be weary of this when building our models, as the model will naturally show more examples of male players.
plt.figure(figsize=(8,4))
data['GameGenre'].value_counts().plot(kind='bar')
plt.title("Game Genre Distribution")
plt.xlabel("Game Genre")
plt.ylabel("Number of Players")
plt.show()
data['GameGenre'].value_counts()
GameGenre Sports 8048 Action 8039 Strategy 8012 Simulation 7983 RPG 7952 Name: count, dtype: int64
The game genre distribution is well balanced across all genres. Each genre has almost the same number of players, with differences of only around 100. The model will therefore not favour one genre over another.
plt.figure(figsize=(6,4))
data['GameDifficulty'].value_counts().plot(kind='bar')
plt.title("Game Difficulty Distribution")
plt.xlabel("Difficulty")
plt.ylabel("Number of Players")
plt.show()
data['GameDifficulty'].value_counts()
GameDifficulty Easy 20015 Medium 12011 Hard 8008 Name: count, dtype: int64
With the game difficulty, there is a clear bias towards the 'easy' level. Most players select easier gameplay, while fewer select hard.
plt.figure(figsize=(8,4))
data['Location'].value_counts().plot(kind='bar')
plt.title("Player Location Distribution")
plt.xlabel("Location")
plt.ylabel("Number of Players")
plt.show()
data['Location'].value_counts()
Location USA 16000 Europe 12004 Asia 8095 Other 3935 Name: count, dtype: int64
The dataset is dominated by players from USA and Europe, with Asia and Other representing a smaller proportion (Other being the smallest).
plt.hist(data['PlayTimeHours'], bins=20)
plt.title("Weekly Play Time Distribution")
plt.xlabel("Hours Played per Week")
plt.ylabel("Number of Players")
plt.show()
data['PlayTimeHours'].describe()
count 40034.000000 mean 12.024365 std 6.914638 min 0.000115 25% 6.067501 50% 12.008002 75% 17.963831 max 23.999592 Name: PlayTimeHours, dtype: float64
The weekly playtime average sits at 12 hours. The median also shows 12 hours - which shows an even distribution of play hours. There doesn't seem to be any exteme outliers in the data.
plt.hist(data['InGamePurchases'], bins=5)
plt.title("In-Game Purchases Distribution")
plt.xlabel("Number of Purchases")
plt.ylabel("Number of Players")
plt.show()
data['InGamePurchases'].value_counts()
InGamePurchases 0 31993 1 8041 Name: count, dtype: int64
The data heavily leans towards non-spending players, meaning that around 80% of players don't make in-app purchases.
plt.hist(data['SessionsPerWeek'], bins=20)
plt.title("Sessions Per Week Distribution")
plt.xlabel("Sessions Per Week")
plt.ylabel("Number of Players")
plt.show()
data['SessionsPerWeek'].describe()
count 40034.000000 mean 9.471774 std 5.763667 min 0.000000 25% 4.000000 50% 9.000000 75% 14.000000 max 19.000000 Name: SessionsPerWeek, dtype: float64
The above data shows a balanced distribution of weekly sessions, with the average player logging around 9 sessions per week, with the median also being 9 sessions a week. The spready is fairly wide - ranging from 0 to 19 sessions. This suggests a range of players form casual gamers to more serious gamers.
plt.hist(data['AvgSessionDurationMinutes'], bins=20)
plt.title("Average Session Duration Distribution")
plt.xlabel("Minutes per Session")
plt.ylabel("Number of Players")
plt.show()
data['AvgSessionDurationMinutes'].describe()
count 40034.000000 mean 94.792252 std 49.011375 min 10.000000 25% 52.000000 50% 95.000000 75% 137.000000 max 179.000000 Name: AvgSessionDurationMinutes, dtype: float64
The average sessions lasts around 95 minutes, with the median also 95 minutes. There is a wide spread of data, showing some casual gamers preferring shorter sessions (10 minutes) while others preferring longer (179 minutes).
plt.hist(data['PlayerLevel'], bins=20)
plt.title("Player Level Distribution")
plt.xlabel("Player Level")
plt.ylabel("Number of Players")
plt.show()
data['PlayerLevel'].describe()
count 40034.000000 mean 49.655568 std 28.588379 min 1.000000 25% 25.000000 50% 49.000000 75% 74.000000 max 99.000000 Name: PlayerLevel, dtype: float64
The average player level is 50 and the median sits at 49. Players are evenly distributed across early, mid and late game levels.
plt.hist(data['AchievementsUnlocked'], bins=20)
plt.title("Achievements Unlocked Distribution")
plt.xlabel("Achievements")
plt.ylabel("Number of Players")
plt.show()
data['AchievementsUnlocked'].describe()
count 40034.000000 mean 24.526477 std 14.430726 min 0.000000 25% 12.000000 50% 25.000000 75% 37.000000 max 49.000000 Name: AchievementsUnlocked, dtype: float64
Again, a balanced distribution is shown here, with the average achievements unlocked sitting at 24.5 and the median at 25. We could make the assumption that higher engagements is associated with more achievements unlocked.
Next we need to preprocess the data so that the model we choose can understand the data. First, we need to remove any unnecessary data. Any data that won't be used to predict engagement needs to be removed, so that the model doesn't learn meaningless patterns.
data = data.drop('PlayerID', axis=1)
data.head()
X = data.drop('EngagementLevel', axis=1)
y = data['EngagementLevel']
Above, we create a new dataset that excludes the engagement level. We don't want the model to be able to see the engagement level as we train it, so we've split the data into input features (X) and the target variable (Y i.e. engagement).
categorical_cols = ['Gender', 'Location', 'GameGenre', 'GameDifficulty']
X = pd.get_dummies(X, columns=categorical_cols, drop_first=True)
X.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 |
The categorical features (Gender, Location, GameGenre, GameDifficulty) cannot be used directly by the model because they contain text values. To fix this, we apply one-hot encoding, which converts each category into true/false columns.
Using pd.get_dummies(..., drop_first=True) ensures the dataset is fully numeric while avoiding redundant dummy columns.
label_encoder = LabelEncoder()
y = label_encoder.fit_transform(y)
dict(zip(label_encoder.classes_, label_encoder.transform(label_encoder.classes_)))
{'High': 0, 'Low': 1, 'Medium': 2}
Above we use label_encoder to convert the engagement category into numerical values so the model can learn to predict them.
X_train, X_test, y_train, y_test = train_test_split(
X,
y,
test_size=0.3,
random_state=42,
stratify=y
)
Above, we split our data into:
- 70% traning data
- 30% testing data
We use stratify=y to ensure that the proportions of High, Medium, and Low engagement levels remain consistent in both the training and test sets.
random_state=42 ensures the split can be reproduced exactly, which is important for consistency in experiments.
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
Support Vector Machines are sensitive to the scale of input features because they measure distances between data points to draw a decision boundary. Because our dataset contains features with different ranges, we need to standaridse the data. To fix this, we standadise the data using StandardScaler(). This transforms every feature so that the mean becomes 0 and the standard deviation becomes 1.
Decision Trees do not require scaling, so we only use the scaled data for the SVM model.
dt_classifier = DecisionTreeClassifier(random_state=42)
dt_classifier.fit(X_train, y_train)
y_train_pred_dt = dt_classifier.predict(X_train)
y_test_pred_dt = dt_classifier.predict(X_test)
print("Train accuracy (Decision Tree):", accuracy_score(y_train, y_train_pred_dt))
print("Test accuracy (Decision Tree):", accuracy_score(y_test, y_test_pred_dt))
Train accuracy (Decision Tree): 1.0 Test accuracy (Decision Tree): 0.8365664807260011
print("\nDecision Tree - Classification Report (Test Data)\n")
print(classification_report(y_test, y_test_pred_dt, target_names=label_encoder.classes_))
Decision Tree - Classification Report (Test Data)
precision recall f1-score support
High 0.81 0.81 0.81 3101
Low 0.80 0.81 0.81 3097
Medium 0.87 0.86 0.87 5813
accuracy 0.84 12011
macro avg 0.83 0.83 0.83 12011
weighted avg 0.84 0.84 0.84 12011
We first train a simple Decision Tree using the default settings on the unscaled features. We then evaluate the model on the test set using: Accuracy, Prevision, Recall, F1-Score.
Training accuracy at a 100% means the model predcits the training data perfectly. This however indicates overfitting, where the tree memorises the training data instead of learning general patterns. Test accuracy at 83.66% confirms the overfitting. This is common for decision trees with default settings. The classification report again shows overfitting. Despite this, it performs relatively well on the test set.
- Medium engagement is predicted most accurately
- High and low engagement are harder to seperate
svm_linear = SVC(kernel='linear')
svm_linear.fit(X_train_scaled, y_train)
y_train_predict_svm_linear = svm_linear.predict(X_train_scaled)
y_test_predict_svm_linear = svm_linear.predict(X_test_scaled)
print("Train accuracy (SVM Linear):", accuracy_score(y_train, y_train_predict_svm_linear))
print("Test accuracy (SVM Linear):", accuracy_score(y_test, y_test_predict_svm_linear))
Train accuracy (SVM Linear): 0.8253934268279628 Test accuracy (SVM Linear): 0.8226625593206227
print("\nSVM (Linear Kernel) - Classification Report (Test Data)\n")
print(classification_report(y_test, y_test_predict_svm_linear, target_names=label_encoder.classes_))
SVM (Linear Kernel) - Classification Report (Test Data)
precision recall f1-score support
High 0.88 0.85 0.87 3101
Low 0.79 0.70 0.74 3097
Medium 0.81 0.87 0.84 5813
accuracy 0.82 12011
macro avg 0.83 0.81 0.82 12011
weighted avg 0.82 0.82 0.82 12011
Above, we trained a Support Vector Machine using a linear kernel to predict player engagement levels.
This model tries to separate the classes (High, Medium, Low) using straight-line decision boundaries
The train and test accuracies are almost identica - shows that, it generalises well to new, unseen players and the decision boundaries are stable and consistent.
ROC-AUC tells us how well the model separates the three engagement levels. To calculate it, we need the model to give probability scores, not just predictions. Setting probability=True lets the SVM do this, and we then use those probabilities to get the ROC-AUC score.
svm_linear_proba = SVC(kernel='linear', probability=True, random_state=42)
svm_linear_proba.fit(X_train_scaled, y_train)
y_test_proba_svm_linear = svm_linear_proba.predict_proba(X_test_scaled)
svm_linear_roc_auc = roc_auc_score(y_test, y_test_proba_svm_linear, multi_class='ovr')
print("ROC-AUC (SVM Linear):", svm_linear_roc_auc)
ROC-AUC (SVM Linear): 0.9149357056855244
Below we use the Decision Tree’s predicted probabilities to calculate its ROC-AUC score. This shows how well the baseline model separates the three engagement levels before any tuning.
# Calculate AUC-ROC for baseline Decision Tree
y_test_proba_dt_baseline = dt_classifier.predict_proba(X_test)
dt_baseline_roc_auc = roc_auc_score(y_test, y_test_proba_dt_baseline, multi_class='ovr')
print("ROC-AUC (Baseline Decision Tree):", dt_baseline_roc_auc)
ROC-AUC (Baseline Decision Tree): 0.8715121654936967
We train an SVM using the RBF kernel, which can capture more complex, curved patterns in the data. After fitting the model, we make predictions on both the training and test sets to see how well it perfors.
svm_rbf = SVC(kernel='rbf')
svm_rbf.fit(X_train_scaled, y_train)
y_train_predict_svm_rbf = svm_rbf.predict(X_train_scaled)
y_test_predict_svm_rbf = svm_rbf.predict(X_test_scaled)
print("Train accuracy (SVM RBF):", accuracy_score(y_train, y_train_predict_svm_rbf))
print("Test accuracy (SVM RBF):", accuracy_score(y_test, y_test_predict_svm_rbf))
Train accuracy (SVM RBF): 0.9067551653998501 Test accuracy (SVM RBF): 0.8830238947631338
These accuracy scores show how well the RBF SVM learned the patterns in the data. The training accuracy is slightly higher than the test accuracy, which is norma. This tells us the model is learning well witholy overfitting and is able to generalise to new players.
print("\nSVM (RBF Kernel) - Classification Report (Test Data)\n")
print(classification_report(y_test, y_test_predict_svm_rbf, target_names=label_encoder.classes_))
SVM (RBF Kernel) - Classification Report (Test Data)
precision recall f1-score support
High 0.91 0.85 0.88 3101
Low 0.88 0.82 0.85 3097
Medium 0.87 0.93 0.90 5813
accuracy 0.88 12011
macro avg 0.89 0.87 0.88 12011
weighted avg 0.88 0.88 0.88 12011
The RBF kernel lets the SVM pick up more curved, non-linear patterns, which fits this kind of gameplay data well. It performs better than both the Decision Tree and the linear SVM, and the training and test scores are close together, so it doesn’t look like the model is overfitting.
Medium engagement has the best recall (0.93), meaning the model is good at spotting players in this group. High and Low engagement have similar precision, and both show an improvement compared to the linear SVM.
Below, the same steps are used here as with the previous ROC-AUC calculation. We enable probability output, get the class probabilities, and then measure how well the model separates the three engagement level. ses.
svm_rbf_proba = SVC(kernel='rbf', probability=True, random_state=42)
svm_rbf_proba.fit(X_train_scaled, y_train)
y_test_proba_svm_rbf_baseline = svm_rbf_proba.predict_proba(X_test_scaled)
svm_rbf_baseline_roc_auc = roc_auc_score(y_test, y_test_proba_svm_rbf_baseline, multi_class='ovr')
print("ROC-AUC (Baseline SVM RBF):", svm_rbf_baseline_roc_auc)
ROC-AUC (Baseline SVM RBF): 0.9406418249231491
The ROC-AUC score for the RBF SVM is about 0.94, which is very strong. This means the model is good at separating High, Medium, and Low engagement players based on their behaviour.
Here we create a Decision Tree model and define a range of settings (max depth, minimum samples per split/leaf, and the splitting criterion) to test. We then use GridSearchCV to try all combinations and find the setup that gives the best performance. We then score the models using weighted F1
dt_model = DecisionTreeClassifier(random_state=42)
param_grid_dt = {
'max_depth': [None, 5, 10, 15, 20],
'min_samples_split': [2, 5, 10],
'min_samples_leaf': [1, 2, 4],
'criterion': ['gini', 'entropy']
}
grid_dt = GridSearchCV(
estimator=dt_model,
param_grid=param_grid_dt,
cv=3,
scoring='f1_weighted',
n_jobs=-1,
verbose=1
)
grid_dt.fit(X_train, y_train)
Fitting 3 folds for each of 90 candidates, totalling 270 fits
GridSearchCV(cv=3, estimator=DecisionTreeClassifier(random_state=42), n_jobs=-1,
param_grid={'criterion': ['gini', 'entropy'],
'max_depth': [None, 5, 10, 15, 20],
'min_samples_leaf': [1, 2, 4],
'min_samples_split': [2, 5, 10]},
scoring='f1_weighted', verbose=1)In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
GridSearchCV(cv=3, estimator=DecisionTreeClassifier(random_state=42), n_jobs=-1,
param_grid={'criterion': ['gini', 'entropy'],
'max_depth': [None, 5, 10, 15, 20],
'min_samples_leaf': [1, 2, 4],
'min_samples_split': [2, 5, 10]},
scoring='f1_weighted', verbose=1)DecisionTreeClassifier(random_state=42)
DecisionTreeClassifier(random_state=42)
print("Best parameters (Decision Tree):", grid_dt.best_params_)
print("Best cross-val F1 (weighted):", grid_dt.best_score_)
Best parameters (Decision Tree): {'criterion': 'entropy', 'max_depth': 10, 'min_samples_leaf': 4, 'min_samples_split': 10}
Best cross-val F1 (weighted): 0.8952118174157863
best_dt = grid_dt.best_estimator_
y_test_pred_best_dt = best_dt.predict(X_test)
print("Test accuracy (Tuned Decision Tree):", accuracy_score(y_test, y_test_pred_best_dt))
print("\nTuned Decision Tree - Classification Report (Test Data)\n")
print(classification_report(y_test, y_test_pred_best_dt, target_names=label_encoder.classes_))
Test accuracy (Tuned Decision Tree): 0.89975855465823
Tuned Decision Tree - Classification Report (Test Data)
precision recall f1-score support
High 0.90 0.89 0.89 3101
Low 0.89 0.85 0.87 3097
Medium 0.90 0.93 0.92 5813
accuracy 0.90 12011
macro avg 0.90 0.89 0.89 12011
weighted avg 0.90 0.90 0.90 12011
Using GridSearchCV, we tried out different tree settings and found the combination that worked best:
- criterion: entropy
- max_depth: 10
- min_samples_split: 10
- min_samples_leaf: 4
These limits keep the tree from growing too large, which helps avoid overfitting.
- Best F1 score during tuning: 0.895
-Test accuracy after tuning: 90%
The tuned model gives more balanced and reliable predictions across the High, Medium, and Low engagement groups.
Next we create an SVM with the RBF kernel and define a small grid of values for C and gamma to try. GridSearchCV will test each combination and find the settings that give the best overall performance. We use weighted F1 as the scoring method so all engagement levels are treated fairly during tuning.
svm_model = SVC(kernel='rbf')
param_grid_svm = {
'C': [0.1, 1, 10],
'gamma': [0.01, 0.1, 1]
}
grid_svm = GridSearchCV(
estimator=svm_model,
param_grid=param_grid_svm,
cv=3,
scoring='f1_weighted',
n_jobs=-1,
verbose=1
)
grid_svm.fit(X_train_scaled, y_train)
Fitting 3 folds for each of 9 candidates, totalling 27 fits
GridSearchCV(cv=3, estimator=SVC(), n_jobs=-1,
param_grid={'C': [0.1, 1, 10], 'gamma': [0.01, 0.1, 1]},
scoring='f1_weighted', verbose=1)In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
GridSearchCV(cv=3, estimator=SVC(), n_jobs=-1,
param_grid={'C': [0.1, 1, 10], 'gamma': [0.01, 0.1, 1]},
scoring='f1_weighted', verbose=1)SVC()
SVC()
print("Best parameters (SVM RBF):", grid_svm.best_params_)
print("Best cross-val F1 (weighted):", grid_svm.best_score_)
Best parameters (SVM RBF): {'C': 10, 'gamma': 0.01}
Best cross-val F1 (weighted): 0.8878940547882807
The grid search found that the best performance came from:
- C = 10
- gamma = 0.01
Using the best parameters found in the grid search, we retrain the SVM and test it on the unseen data:
best_svm = grid_svm.best_estimator_
y_test_pred_best_svm = best_svm.predict(X_test_scaled)
print("Test accuracy (Tuned SVM RBF):", accuracy_score(y_test, y_test_pred_best_svm))
print("\nTuned SVM RBF - Classification Report (Test Data)\n")
print(classification_report(y_test, y_test_pred_best_svm, target_names=label_encoder.classes_))
Test accuracy (Tuned SVM RBF): 0.8897677129298144
Tuned SVM RBF - Classification Report (Test Data)
precision recall f1-score support
High 0.92 0.86 0.89 3101
Low 0.89 0.83 0.86 3097
Medium 0.88 0.94 0.91 5813
accuracy 0.89 12011
macro avg 0.90 0.88 0.88 12011
weighted avg 0.89 0.89 0.89 12011
From the classification report, Medium engagement has the strongest recall, meaning the model correctly identifies most Medium-level players. High and Low engagement both show good precision and balanced F1-scores
y_test_proba_dt = best_dt.predict_proba(X_test)
dt_roc_auc = roc_auc_score(y_test, y_test_proba_dt, multi_class='ovr')
print("ROC-AUC (Tuned Decision Tree, OvR):", dt_roc_auc)
ROC-AUC (Tuned Decision Tree, OvR): 0.9321401413824558
A score of 0.93 is a strong result. It shows that the improved model is much better at seperating the three engagement classes
svm_proba = SVC(kernel='rbf', C=best_svm.C, gamma=best_svm.gamma, probability=True, random_state=42)
svm_proba.fit(X_train_scaled, y_train)
y_test_proba_svm = svm_proba.predict_proba(X_test_scaled)
svm_roc_auc = roc_auc_score(y_test, y_test_proba_svm, multi_class='ovr')
print("ROC-AUC (Tuned SVM RBF, OvR):", svm_roc_auc)
ROC-AUC (Tuned SVM RBF, OvR): 0.9417799327180895
To get the ROC-AUC score for the tuned SVM, we retrain it with the best parameters and enable probability outputs. 0.94 is the best score amongst all the models tested.
# Model Performance Comparison
results_summary = pd.DataFrame({
'Model': ['Baseline DT', 'SVM Linear', 'Baseline SVM RBF', 'Tuned DT', 'Tuned SVM RBF'],
'Test Accuracy': [0.8366, 0.8227, 0.8830, 0.8998, 0.8898],
'Weighted F1': [0.84, 0.82, 0.88, 0.90, 0.89],
'AUC-ROC': [
dt_baseline_roc_auc,
svm_linear_roc_auc,
svm_rbf_baseline_roc_auc,
dt_roc_auc,
svm_roc_auc
]
})
print(results_summary.to_string(index=False))
Model Test Accuracy Weighted F1 AUC-ROC
Baseline DT 0.8366 0.84 0.871512
SVM Linear 0.8227 0.82 0.914936
Baseline SVM RBF 0.8830 0.88 0.940642
Tuned DT 0.8998 0.90 0.932140
Tuned SVM RBF 0.8898 0.89 0.941780
The tuned Decision Tree achieves the best accuracy and weighted F1 score, showing it performs very well across all classes when tuned properly. The tuned RBF SVM, however, has the highest ROC-AUC, meaning it does the best job at separating the three engagement levels.
Overall, both tuned models perform strongly, with the Decision Tree offering more interpretability and the SVM offering slightly better ranking ability.
Confustion Matrix:
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
cm_dt = confusion_matrix(y_test, y_test_pred_best_dt)
disp_dt = ConfusionMatrixDisplay(confusion_matrix=cm_dt,
display_labels=label_encoder.classes_)
disp_dt.plot(ax=axes[0], cmap='Blues', values_format='d')
axes[0].set_title('Confusion Matrix: Tuned Decision Tree', fontsize=14, fontweight='bold')
axes[0].set_xlabel('Predicted Label', fontsize=12)
axes[0].set_ylabel('True Label', fontsize=12)
cm_svm = confusion_matrix(y_test, y_test_pred_best_svm)
disp_svm = ConfusionMatrixDisplay(confusion_matrix=cm_svm,
display_labels=label_encoder.classes_)
disp_svm.plot(ax=axes[1], cmap='Oranges', values_format='d')
axes[1].set_title('Confusion Matrix: Tuned SVM RBF', fontsize=14, fontweight='bold')
axes[1].set_xlabel('Predicted Label', fontsize=12)
axes[1].set_ylabel('True Label', fontsize=12)
plt.tight_layout()
plt.show()
These confusion matrices show how the tuned Decision Tree and tuned RBF SVM perform acros the three engagement levels. Both models correctly ident fy most Medium-engagement player, which is expected since this is the largest group and has clearer patterns.
High and Low engagement are where most mistakes happen. Both models sometimes confuse hese with Medium, showing that the boundaries between Medium–High and Medium–Low aae not lways clear in the data.
The Decision Tree is slightly better at identifying High and Low players, while th RBF SVM is slightly better at recognising Medium players. Overall, both models perform imilarly, with the SVM having a small edge in overall classseparation. s.
Next we'll use K-Means clustering to try and identify any patterns relating to engagement within the dataset
cluster_data = data.drop('EngagementLevel', axis=1)
cluster_data_encoded = pd.get_dummies(cluster_data, drop_first=True)
scaler_cluster = StandardScaler()
cluster_scaled = scaler_cluster.fit_transform(cluster_data_encoded)
First we'll cluster on all features:
inertia_values = []
silhouette_values = []
K = range(2, 10)
for k in K:
kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
labels = kmeans.fit_predict(cluster_scaled)
inertia_values.append(kmeans.inertia_)
silhouette_values.append(silhouette_score(cluster_scaled, labels))
Before choosing a number of clusters, we used two common methods to check whether the data naturally forms clear groups.
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
axes[0].plot(K, inertia_values, marker='o')
axes[0].set_title("Elbow Method (All Features)")
axes[0].set_xlabel("Number of Clusters (k)")
axes[0].set_ylabel("Inertia")
axes[0].grid(True, alpha=0.3)
axes[1].plot(K, silhouette_values, marker='o', color='orange')
axes[1].set_title("Silhouette Score (All Features)")
axes[1].set_xlabel("Number of Clusters (k)")
axes[1].set_ylabel("Silhouette Score")
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
kmeans_initial = KMeans(n_clusters=4, random_state=42, n_init=10)
cluster_labels = kmeans_initial.fit_predict(cluster_scaled)
data['Cluster_Initial'] = cluster_labels
print(data['Cluster_Initial'].value_counts().sort_index())
Cluster_Initial 0 7952 1 10411 2 8048 3 13623 Name: count, dtype: int64
cluster_summary = data.groupby('Cluster_Initial')[
['PlayTimeHours', 'SessionsPerWeek', 'AvgSessionDurationMinutes',
'InGamePurchases', 'PlayerLevel', 'AchievementsUnlocked']
].mean().round(2)
print(cluster_summary)
PlayTimeHours SessionsPerWeek AvgSessionDurationMinutes \
Cluster_Initial
0 12.01 9.41 93.74
1 11.96 9.44 94.79
2 11.97 9.50 95.05
3 12.12 9.52 95.26
InGamePurchases PlayerLevel AchievementsUnlocked
Cluster_Initial
0 0.2 49.43 24.52
1 0.2 49.33 24.60
2 0.2 49.89 24.40
3 0.2 49.89 24.55
K-means was applied using k = 4, as supported by the silhouette scores.
However, the cluster profiles were almost identical, showing no real differences using all features. Too much noise obscured any patterns within the data.
Next, we'll explore k-means clustering with behavioural features only
behavioural_features = data[['PlayTimeHours', 'SessionsPerWeek', 'AvgSessionDurationMinutes']].copy()
scaler_behavioural = StandardScaler()
behavioural_scaled = scaler_behavioural.fit_transform(behavioural_features)
inertia_values_behavioural = []
silhouette_values_behavioural = []
K_range = range(2, 8)
for k in K_range:
kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
labels = kmeans.fit_predict(behavioural_scaled)
inertia_values_behavioural.append(kmeans.inertia_)
silhouette_values_behavioural.append(silhouette_score(behavioural_scaled, labels))
print(f"k={k}: Silhouette Score = {silhouette_score(behavioural_scaled, labels):.4f}")
k=2: Silhouette Score = 0.2442 k=3: Silhouette Score = 0.2399 k=4: Silhouette Score = 0.2645 k=5: Silhouette Score = 0.2672 k=6: Silhouette Score = 0.2864 k=7: Silhouette Score = 0.2807
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
axes[0].plot(K_range, inertia_values_behavioural, marker='o', linewidth=2, markersize=8)
axes[0].set_xlabel('Number of Clusters (k)')
axes[0].set_ylabel('Inertia')
axes[0].set_title('Elbow Method (Behavioural Features)')
axes[0].grid(True, alpha=0.3)
axes[1].plot(K_range, silhouette_values_behavioural, marker='o', linewidth=2, markersize=8, color='orange')
axes[1].set_xlabel('Number of Clusters (k)')
axes[1].set_ylabel('Silhouette Score')
axes[1].set_title('Silhouette Score (Behavioral Features)')
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Even though k=6 had a slightly higher silhouette score, the improvement over k=4 was very small, and the elbow method still didn’t show a clear natural number of clusters. For simplicity and interpretability, k=4 was chosen for the behavioural clustering
kmeans_final = KMeans(n_clusters=4, random_state=42, n_init=10)
data['Final_Cluster'] = kmeans_final.fit_predict(behavioural_scaled)
cluster_profiles = data.groupby('Final_Cluster')[
['Age', 'PlayTimeHours', 'SessionsPerWeek', 'AvgSessionDurationMinutes',
'InGamePurchases', 'PlayerLevel', 'AchievementsUnlocked']
].agg(['mean', 'std']).round(2)
print(cluster_profiles)
Age PlayTimeHours SessionsPerWeek \
mean std mean std mean std
Final_Cluster
0 32.17 10.05 5.38 3.37 9.38 5.29
1 32.00 10.11 12.08 6.40 15.02 2.84
2 31.87 10.01 12.14 6.35 4.01 2.81
3 31.93 10.00 18.56 3.42 9.41 5.31
AvgSessionDurationMinutes InGamePurchases \
mean std mean std
Final_Cluster
0 55.22 28.62 0.2 0.4
1 134.00 28.73 0.2 0.4
2 135.22 28.04 0.2 0.4
3 54.07 27.98 0.2 0.4
PlayerLevel AchievementsUnlocked
mean std mean std
Final_Cluster
0 49.90 28.63 24.36 14.49
1 49.67 28.56 24.64 14.44
2 49.66 28.59 24.54 14.36
3 49.40 28.57 24.56 14.44
The four clusters show different ways players spend their time in the game:
- Cluster 0:Moderate number of sessions with shorter play times.
- Cluster 1: Very frequent sessions with long play times.
- Cluster 2: Fewer sessions, but each session lasts a long time.
- Cluster 3: Very frequent sessions, but shorter play times.
Other features like purchases and level are almost the same across clusters, so the main differences come from playtime and session patterns.
cluster_sizes = data['Final_Cluster'].value_counts().sort_index()
print(cluster_sizes)
print("\nPercentage distribution:")
print((cluster_sizes / len(data) * 100).round(2))
Final_Cluster 0 10012 1 10110 2 9989 3 9923 Name: count, dtype: int64 Percentage distribution: Final_Cluster 0 25.01 1 25.25 2 24.95 3 24.79 Name: count, dtype: float64
All four clusters are almost the same size, each making up around 25% of the players. This shows that the clustering didn’t create any very small or very large groups, and each behaviour pattern is represented fairly evenly in the dataset.
engagement_crosstab = pd.crosstab(data['Final_Cluster'], data['EngagementLevel'], normalize='index')
print(engagement_crosstab.round(3))
EngagementLevel High Low Medium Final_Cluster 0 0.057 0.341 0.602 1 0.838 0.032 0.130 2 0.071 0.320 0.610 3 0.059 0.342 0.599
Cluster 1 clearly stands out, with almost 84% of its players in the High engagement group. The other three clusters have very similar engagement distributions, each with around 60% Medium engagement and only a small proportion of High engagement playersurs.
for cluster_id in sorted(data['Final_Cluster'].unique()):
cluster_data_subset = data[data['Final_Cluster'] == cluster_id]
print(f"\nCluster {cluster_id}: (n={len(cluster_data_subset)}, {len(cluster_data_subset)/len(data)*100:.1f}%)")
print(f" Avg Play Time: {cluster_data_subset['PlayTimeHours'].mean():.2f} hrs perweek")
print(f" Avg Sessions: {cluster_data_subset['SessionsPerWeek'].mean():.2f} sessions per week")
print(f" Avg Session Duration: {cluster_data_subset['AvgSessionDurationMinutes'].mean():.2f} minutes")
print(f" In-Game Purchases: {cluster_data_subset['InGamePurchases'].mean():.2f}")
high_eng_pct = (cluster_data_subset['EngagementLevel']=='High').sum()/len(cluster_data_subset)*100
print(f" High Engagement %: {high_eng_pct:.1f}%")
if cluster_id == 0:
label = "MODERATE FREQUENCY, SHORT DURATION"
elif cluster_id == 1:
label = "HIGH FREQUENCY, EXTENDED DURATION"
elif cluster_id == 2:
label = "LOW FREQUENCY, EXTENDED DURATION"
else:
label = "MODERATE FREQUENCY, SHORT DURATION (HIGH VOLUME)"
print(f" Label: {label}")
Cluster 0: (n=10012, 25.0%) Avg Play Time: 5.38 hrs perweek Avg Sessions: 9.38 sessions per week Avg Session Duration: 55.22 minutes In-Game Purchases: 0.20 High Engagement %: 5.7% Label: MODERATE FREQUENCY, SHORT DURATION Cluster 1: (n=10110, 25.3%) Avg Play Time: 12.08 hrs perweek Avg Sessions: 15.02 sessions per week Avg Session Duration: 134.00 minutes In-Game Purchases: 0.20 High Engagement %: 83.8% Label: HIGH FREQUENCY, EXTENDED DURATION Cluster 2: (n=9989, 25.0%) Avg Play Time: 12.14 hrs perweek Avg Sessions: 4.01 sessions per week Avg Session Duration: 135.22 minutes In-Game Purchases: 0.20 High Engagement %: 7.1% Label: LOW FREQUENCY, EXTENDED DURATION Cluster 3: (n=9923, 24.8%) Avg Play Time: 18.56 hrs perweek Avg Sessions: 9.41 sessions per week Avg Session Duration: 54.07 minutes In-Game Purchases: 0.20 High Engagement %: 5.9% Label: MODERATE FREQUENCY, SHORT DURATION (HIGH VOLUME)
Each cluster shows a different play style:
- Cluster 0: Moderate number of sessions with shorter play times. Only 6% are highly engaged.
- Cluster 1: Frequent sessions and long play times. This is the clear high-engagement group (84%).
- Cluster 2: Few sessions per week but long sessions. High engagement is still low (7%).
- Cluster 3: Similar to Cluster 0 but with more total hours played. High engagement remains low (6%).
Overall, only one behaviour pattern (Cluster 1) is strongly linked to high engagement. The other clusters behave differently but show similar, low high-engagement levels.
gender_profile = data.groupby('Gender')[
['PlayTimeHours', 'SessionsPerWeek', 'InGamePurchases', 'PlayerLevel']
].mean().round(2)
print(gender_profile)
PlayTimeHours SessionsPerWeek InGamePurchases PlayerLevel Gender Female 11.97 9.52 0.2 49.42 Male 12.06 9.44 0.2 49.81
gender_engagement = pd.crosstab(data['Gender'], data['EngagementLevel'], normalize='index')
print(gender_engagement.round(3))
EngagementLevel High Low Medium Gender Female 0.261 0.256 0.483 Male 0.256 0.259 0.485
location_profile = data.groupby('Location')[
['PlayTimeHours', 'SessionsPerWeek', 'InGamePurchases', 'PlayerLevel']
].mean().round(2)
print(location_profile)
PlayTimeHours SessionsPerWeek InGamePurchases PlayerLevel Location Asia 12.19 9.49 0.21 49.84 Europe 11.93 9.48 0.20 49.24 Other 12.06 9.44 0.19 50.18 USA 12.00 9.46 0.20 49.74
location_engagement = pd.crosstab(data['Location'], data['EngagementLevel'], normalize='index')
print(location_engagement.round(3))
EngagementLevel High Low Medium Location Asia 0.263 0.254 0.483 Europe 0.256 0.262 0.483 Other 0.257 0.263 0.480 USA 0.258 0.255 0.487
difficulty_profile = data.groupby('GameDifficulty')[
['PlayTimeHours', 'SessionsPerWeek', 'InGamePurchases', 'PlayerLevel']
].mean().round(2)
print(difficulty_profile)
PlayTimeHours SessionsPerWeek InGamePurchases PlayerLevel GameDifficulty Easy 12.01 9.44 0.2 49.51 Hard 12.04 9.50 0.2 49.97 Medium 12.03 9.51 0.2 49.69
difficulty_engagement = pd.crosstab(data['GameDifficulty'], data['EngagementLevel'], normalize='index')
print(difficulty_engagement.round(3))
EngagementLevel High Low Medium GameDifficulty Easy 0.256 0.260 0.483 Hard 0.264 0.259 0.477 Medium 0.257 0.253 0.489
genre_profile = data.groupby('GameGenre')[
['PlayTimeHours', 'SessionsPerWeek', 'InGamePurchases', 'PlayerLevel']
].mean().round(2)
print(genre_profile)
PlayTimeHours SessionsPerWeek InGamePurchases PlayerLevel GameGenre Action 12.16 9.47 0.19 49.80 RPG 12.01 9.41 0.20 49.43 Simulation 11.90 9.44 0.20 49.25 Sports 11.97 9.50 0.20 49.89 Strategy 12.08 9.54 0.21 49.90
genre_engagement = pd.crosstab(data['GameGenre'], data['EngagementLevel'], normalize='index')
print(genre_engagement.round(3))
EngagementLevel High Low Medium GameGenre Action 0.257 0.256 0.487 RPG 0.250 0.264 0.486 Simulation 0.264 0.262 0.475 Sports 0.259 0.258 0.483 Strategy 0.262 0.250 0.488
gender_cluster = pd.crosstab(data['Final_Cluster'], data['Gender'], normalize='columns')
print(gender_cluster.round(3))
Gender Female Male Final_Cluster 0 0.249 0.251 1 0.256 0.250 2 0.249 0.250 3 0.246 0.249
location_cluster = pd.crosstab(data['Final_Cluster'], data['Location'], normalize='columns')
print(location_cluster.round(3))
Location Asia Europe Other USA Final_Cluster 0 0.244 0.257 0.252 0.248 1 0.253 0.251 0.249 0.254 2 0.250 0.248 0.254 0.249 3 0.253 0.244 0.245 0.249
genre_cluster = pd.crosstab(data['Final_Cluster'], data['GameGenre'], normalize='columns')
print(genre_cluster.round(3))
GameGenre Action RPG Simulation Sports Strategy Final_Cluster 0 0.244 0.253 0.248 0.254 0.251 1 0.252 0.243 0.254 0.259 0.255 2 0.249 0.248 0.250 0.248 0.253 3 0.255 0.256 0.248 0.239 0.241
The above data shows that demographics doesn't have create any meaningful patterns. Therefore, the company should focus on behavioural features, as highlighted further above.