Case Study: Predict Online Gaming Behaviour¶
Context¶
A gaming development studio that serves a large and diverse player base across different regions and gaming genres, wants to better understand player behaviour and improve how it engages and retains its users over time. A significant amount of effort and resources can be spent on features, promotions, and in-game strategies designed to keep players active. To make these decisions more effectively, the studio wants to identify which players are highly engaged, moderately engaged, or at risk of lower engagement. As part of the data analytics team, you have been given access to the Predict Online Gaming Behaviour Dataset from Kaggle, which contains information on player demographics, gaming habits, and in-game behaviour. Your task is to analyse the characteristics of different players, identify behavioural patterns, and build machine learning models to predict player engagement level (High, Medium, or Low). This information can then be used to support business decisions related to player engagement, retention, and game design.
Objective¶
- To develop and evaluate predictive models for player engagement using Decision Tree and Support Vector Machine (SVM) classifiers
- To enhance model performance through hyperparameter tuning and assess improvements using standard evaluation metrics
- To apply a clustering algorithm to uncover hidden player segments based on behavioural and demographic features
- To compare and interpret the insights gained from supervised and unsupervised learning approaches
- To translate the findings into practical recommendations that support business decisions related to player engagement, retention, and game design
Dataset Description¶
The data contains information on players' demographic details, gaming behaviour, progression metrics, and engagement level.
- PlayerID - Unique player identifier
- Age - Age of the player
- Gender - Player's gender
- Location - Player's geographic location or region
- GameGenre - Type of game played, such as Action, Strategy, Sports, RPG, or Simulation
- PlayTimeHours - Total time spent playing, measured in hours
- InGamePurchases - Whether the player has made in-game purchases or not
- GameDifficulty - Difficulty level of the game, such as Easy, Medium, or Hard
- SessionsPerWeek - Number of gaming sessions played per week
- AvgSessionDurationMinutes - Average duration of each gaming session in minutes
- PlayerLevel - Current progression level of the player
- AchievementsUnlocked - Number of achievements unlocked by the player
- EngagementLevel - Player engagement category, classified as High, Medium, or Low
Importing the libraries and overview of the dataset¶
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
# To scale the data using z-score
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
# Algorithms to use
from sklearn import tree
from sklearn.tree import DecisionTreeClassifier
from sklearn.svm import SVC
# Metrics to evaluate the model
from sklearn import metrics
from sklearn.metrics import confusion_matrix, classification_report, precision_recall_curve, recall_score
from sklearn.metrics import roc_auc_score
# To ignore warnings
import warnings
warnings.filterwarnings("ignore")
Loading the Dataset¶
# Loading the dataset
df = pd.read_csv('C:\online_gaming_behavior_dataset.csv')
df.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 |
Checking the info of the dataset¶
# Let us see the info of the data
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
Observations:
There are 40034 observations and 13 columns in the dataset. All the columns have 40034 non-null values, i.e., there are no missing values in the data.
Let's check the unique values in each column
# Checking unique values in each column
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
Observations:
- PlayerID is an identifier which is unique for each player and we can drop this column as it would not add any value to our analysis.
- EngagementLevel has 3 unique values (High, Medium, Low), confirming that this is a multiclass classification.
Let's drop the column mentioned above and define lists for numerical and categorical columns to explore them separately.
# Dropping the columns
df=df.drop(['PlayerID'],axis=1) # axis 1 means column
# Creating numerical columns
num_cols = ['Age', 'PlayTimeHours', 'SessionsPerWeek',
'AvgSessionDurationMinutes', 'PlayerLevel',
'AchievementsUnlocked']
# Creating categorical variables
cat_cols = ['Gender', 'Location', 'GameGenre',
'InGamePurchases', 'GameDifficulty',
'EngagementLevel']
Exploratory Data Analysis and Data Preprocessing¶
Univariate analysis of numerical columns¶
# Checking summary statistics
df[num_cols].describe().T
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| Age | 40034.0 | 31.992531 | 10.043227 | 15.000000 | 23.000000 | 32.000000 | 41.000000 | 49.000000 |
| PlayTimeHours | 40034.0 | 12.024365 | 6.914638 | 0.000115 | 6.067501 | 12.008002 | 17.963831 | 23.999592 |
| SessionsPerWeek | 40034.0 | 9.471774 | 5.763667 | 0.000000 | 4.000000 | 9.000000 | 14.000000 | 19.000000 |
| AvgSessionDurationMinutes | 40034.0 | 94.792252 | 49.011375 | 10.000000 | 52.000000 | 95.000000 | 137.000000 | 179.000000 |
| PlayerLevel | 40034.0 | 49.655568 | 28.588379 | 1.000000 | 25.000000 | 49.000000 | 74.000000 | 99.000000 |
| AchievementsUnlocked | 40034.0 | 24.526477 | 14.430726 | 0.000000 | 12.000000 | 25.000000 | 37.000000 | 49.000000 |
Observations:
The average player age is 31.992531 (32) years. Age ranges from 15 to 49, showing that the dataset contains players across a fairly wide age span. At least 50% of players are aged 32 or below, while 75% are aged 41 or below.
The average play time is 12.024365 hours. It has a very wide range, from 0.000115 to 23.999592 hours, indicating large variation in how long players spend gaming. At least 50% of players have a play time of 12.008002 hours or less, while the upper quartile value of 17.963831 shows that a substantial proportion of players spend considerably more time playing.
The average number of sessions per week is 9.471774. The values range from 0 to 19, suggesting that some players are inactive or barely active, while others play very frequently. At least 50% of players have 9 sessions per week or fewer, and 75% have 14 sessions or fewer.
The average session duration is 94.792252 minutes. Session duration ranges from 10 to 179 minutes, which indicates notable variation in player behaviour. At least 50% of players have an average session duration of 95 minutes or less, while the upper quartile of 137 minutes suggests that many players spend long periods in a single session.
The average player level is 49.655568. Player level ranges from 1 to 99, showing a broad spread from very new players to highly progressed ones. At least 50% of players are at level 49 or below, while 75% are at level 74 or below.
The average number of achievements unlocked is 24.526477. The values range from 0 to 49, showing substantial variation in progression and accomplishment. At least 50% of players have unlocked 25 achievements or fewer, while 75% have unlocked 37 or fewer.
Overall, the numerical variables suggest that the dataset contains substantial variation in player age, play time, session frequency, session duration, progression level, and achievements unlocked. These differences are likely to be useful in explaining and predicting player engagement levels.
Let's explore these variables in some more depth by observing their distributions
# Creating histograms
df[num_cols].hist(figsize=(14,14))
plt.show()
Observations:
The Age distribution appears fairly uniform, with players spread quite evenly between the ages of 15 and 49. This suggests that the dataset contains a broad mix of younger and older players rather than being concentrated around one dominant age group.
The PlayTimeHours distribution is also broadly uniform, indicating that players are fairly evenly distributed across low, medium, and high play time ranges. This suggests the dataset includes both casual players and highly active players.
The SessionsPerWeek variable appears evenly distributed across its range, showing that players vary considerably in how often they play each week. There does not appear to be a strong concentration around only low or high session counts.
The AvgSessionDurationMinutes distribution is close to uniform, suggesting that players are spread across short, medium, and long session durations. This indicates substantial variation in how much time different players spend in each gaming session.
The PlayerLevel distribution appears fairly even across the range, indicating that the dataset includes a balanced spread of beginner, intermediate, and advanced players. There is no clear dominance of one particular progression band.
The AchievementsUnlocked distribution also appears broadly uniform, showing that players are distributed across low and high achievement counts. This suggests the dataset captures a wide range of accomplishment and progression behaviour.
Overall, the numerical variables do not show strong skewness or pronounced peaks. Instead, most of the distributions appear relatively uniform, which suggests that the dataset has been constructed to include a wide variety of player profiles across age, play habits, progression, and achievement levels.
Univariate analysis for categorical variables¶
# Printing the % sub categories of each category
for i in cat_cols:
print(df[i].value_counts (normalize=True))
print('*'*40)
Gender Male 0.598466 Female 0.401534 Name: proportion, dtype: float64 **************************************** Location USA 0.399660 Europe 0.299845 Asia 0.202203 Other 0.098291 Name: proportion, dtype: float64 **************************************** GameGenre Sports 0.201029 Action 0.200804 Strategy 0.200130 Simulation 0.199406 RPG 0.198631 Name: proportion, dtype: float64 **************************************** InGamePurchases 0 0.799146 1 0.200854 Name: proportion, dtype: float64 **************************************** GameDifficulty Easy 0.49995 Medium 0.30002 Hard 0.20003 Name: proportion, dtype: float64 **************************************** EngagementLevel Medium 0.483939 High 0.258181 Low 0.257881 Name: proportion, dtype: float64 ****************************************
Observations:
The dataset contains more Male players than Female players. Male players make up 0.598466 (59.85%) of the dataset, while Female players make up 0.401534 (40.15%).
The largest player location group is the USA, accounting for 0.399660 (39.97%) of players. This is followed by Europe at 0.299845 (29.98%), Asia at 0.202203 (20.22%), and Other locations at 0.098291 (9.83%).
The distribution of GameGenre is very balanced. Sports, Action, Strategy, Simulation, and RPG each represent roughly 20% of the dataset, meaning no single game genre dominates the player base.
Most players have not made in-game purchases. 0.799146 (79.91%) of players have no in-game purchases, while 0.200854 (20.09%) have made purchases.
The most common game difficulty is Easy, representing 0.499950 (50.00%) of players. Medium difficulty accounts for 0.300020 (30.00%), while Hard difficulty accounts for 0.200030 (20.00%).
The target variable, EngagementLevel, shows that Medium engagement is the largest class at 0.483939 (48.39%). High engagement accounts for 0.258181 (25.82%), while Low engagement accounts for 0.257881 (25.79%).
Overall, the categorical variables show a reasonably balanced dataset for game genre and engagement level, although there are clearer differences in gender, location, in-game purchases, and game difficulty. These variables may be useful in explaining patterns in player engagement.
Bivariate and Multivariate analysis¶
We have analyzed different categorical and numerical variables. Let's now check how does attrition rate is related with other categorical variables.
for i in cat_cols:
if i!='EngagementLevel':
(pd.crosstab(df[i],df['EngagementLevel'],normalize='index')*100).plot(kind='bar',figsize=(8,4),stacked=True)
plt.ylabel('EngagementLevel %')
Observations:
The engagement distribution appears broadly similar across Gender, with both Male and Female players showing a similar split between Low, Medium, and High engagement. This suggests that gender does not appear to have a strong visible relationship with engagement level.
Across Location, the distribution of engagement levels is also fairly consistent. Players from Asia, Europe, USA, and Other locations all show Medium engagement as the largest category, with Low and High engagement appearing relatively balanced.
The GameGenre chart shows very little variation between Action, RPG, Simulation, Sports, and Strategy players. Each genre has a similar proportion of Low, Medium, and High engagement, suggesting that game genre alone may not strongly explain engagement differences.
For InGamePurchases, players who made purchases and those who did not show a very similar engagement pattern. This suggests that purchasing behaviour alone does not clearly separate highly engaged players from lower engaged players.
GameDifficulty also shows a similar pattern across Easy, Medium, and Hard settings. Medium engagement remains the largest group in each difficulty level, while Low and High engagement are fairly close in proportion.
Overall, the bivariate analysis suggests that none of the categorical variables show a very strong visual relationship with EngagementLevel on their own. This indicates that player engagement may be influenced more by combinations of features rather than one single categorical variable.
Let's check the relationship between EngagementLevel and Numerical variables
# Mean of numerical variables grouped by EngagementLevel
df.groupby(['EngagementLevel'])[num_cols].mean()
| Age | PlayTimeHours | SessionsPerWeek | AvgSessionDurationMinutes | PlayerLevel | AchievementsUnlocked | |
|---|---|---|---|---|---|---|
| EngagementLevel | ||||||
| High | 31.920085 | 12.069238 | 14.254547 | 131.921827 | 50.823723 | 25.095975 |
| Low | 31.896939 | 12.104915 | 4.530511 | 66.882119 | 46.101414 | 22.661565 |
| Medium | 32.082120 | 11.957503 | 9.553267 | 89.856405 | 50.926293 | 25.216424 |
Observations:
The average Age is very similar across all engagement levels. High engagement players have an average age of 31.920085, Low engagement players have an average age of 31.896939, and Medium engagement players have an average age of 32.082120. This suggests that age does not appear to strongly separate engagement levels.
PlayTimeHours is also very similar across the three groups, with High engagement players averaging 12.069238, Low engagement players averaging 12.104915, and Medium engagement players averaging 11.957503. This indicates that total play time alone may not be a strong predictor of engagement level.
SessionsPerWeek shows a clear difference between engagement levels. High engagement players average 14.254547 sessions per week, Medium engagement players average 9.553267, and Low engagement players average only 4.530511. This suggests that session frequency is strongly related to player engagement.
AvgSessionDurationMinutes also shows a clear pattern. High engagement players have the longest average session duration at 131.921827 minutes, followed by Medium engagement players at 89.856405 minutes, and Low engagement players at 66.882119 minutes. This indicates that more engaged players tend to have longer gaming sessions.
PlayerLevel is lowest for Low engagement players at 46.101414, while Medium and High engagement players have higher averages of 50.926293 and 50.823723 respectively. This suggests that lower engagement may be linked with lower player progression.
AchievementsUnlocked follows a similar pattern, with Low engagement players averaging 22.661565, compared with 25.095975 for High engagement and 25.216424 for Medium engagement. This suggests that players with higher engagement tend to unlock more achievements.
Overall, the strongest numerical differences are seen in SessionsPerWeek and AvgSessionDurationMinutes, suggesting that how often players play and how long they play for are likely to be important indicators of engagement level.
Let's check the relationship between different numerical variables¶
# Plotting the correlation between numerical variables
plt.figure(figsize=(15,8))
sns.heatmap(df[num_cols].corr(), annot=True, fmt='0.2f', cmap='YlOrRd')
<Axes: >
Observations:
- The correlation heatmap shows that there are no strong linear relationships between the numerical variables, as almost all correlation coefficients are close to 0.00.
- Variables such as Age, PlayTimeHours, SessionsPerWeek, and AvgSessionDurationMinutes appear to have very weak or negligible correlations with the other numerical features, suggesting that these measures behave largely independently in linear terms.
- Overall, the numerical variables show little risk of multicollinearity, which is useful for modelling, but it also suggests that player engagement may be influenced more by non-linear patterns, categorical variables, or combinations of features rather than simple linear relationships.
Now we have explored our data. Let's build the model
Model Building - Approach¶
- Data preparation.
- Partition the data into a train and test set.
- Build a model on the train data.
- Tune the model if required.
- Test the data on the test set.
Data Preparation¶
Creating dummy variables for categorical Variables
# Creating list of dummy columns
to_get_dummies_for = ['Gender', 'Location', 'GameGenre', 'GameDifficulty']
# Creating dummy variables
df = pd.get_dummies(data = df, columns = to_get_dummies_for, drop_first = True)
# Mapping target variable
dict_engagement = {'Low': 0, 'Medium': 1, 'High': 2}
df['EngagementLevel'] = df.EngagementLevel.map(dict_engagement)
Separating the independent variables (X) and the dependent variable (Y)
Y = df.EngagementLevel
X = df.drop(columns = ['EngagementLevel'])
Splitting the data into 70% train and 30% test set
Some classification problems can exhibit a large imbalance in the distribution of the target classes: for instance there could be several times more negative samples than positive samples. In such cases it is recommended to use the stratified sampling technique to ensure that relative class frequencies are approximately preserved in each train and validation fold.
# Splitting the data
X_train, X_test, y_train, y_test = train_test_split(X, Y, test_size = 0.3, random_state = 1, stratify = Y)
Scaling the data¶
Feature scaling is needed because the independent variables in this dataset are measured on different scales. When features have different magnitudes, algorithms such as Support Vector Machine (SVM) can become biased towards variables with larger values. To avoid this, the data is scaled so that each variable is placed on a comparable scale.
We will use the StandardScaler method, which standardizes the data using the Z-score:
z = (x - u) / s
where u is the mean of the training samples and s is the standard deviation of the training samples.
Decision Tree does not require feature scaling because tree-based models are not sensitive to the variance in the data. However, SVM does require scaling, so the scaled dataset will be used for that model.
# Scaling the data
sc = StandardScaler()
# Fit_transform on train data
X_train_scaled = sc.fit_transform(X_train)
X_train_scaled = pd.DataFrame(X_train_scaled, columns = X.columns)
# Transform on test data
X_test_scaled = sc.transform(X_test)
X_test_scaled = pd.DataFrame(X_test_scaled, columns = X.columns)
Model evaluation criterion¶
The model can make several types of wrong predictions because this is a multiclass classification problem with three target classes: Low, Medium, and High engagement:
For example, the model may:
- Predict that a player has High engagement when the player actually has Medium or Low engagement
- Predict that a player has Medium engagement when the player actually has Low or High engagement
- Predict that a player has Low engagement when the player actually has Medium or High engagement
Which case is more important?
Predicting that a player is Medium or High engagement when the player is actually Low engagement, because this means the business may fail to identify players who are at greater risk of disengagement, lower retention, or churn.
How to reduce this loss?
To reduce this problem, the model should place strong emphasis on Recall, especially for the Low engagement class. A higher recall for this class means the model is better at correctly identifying players with low engagement, allowing the company to target them with retention strategies, personalised incentives, or game design improvements.
Evaluation metrics
Since this is a multiclass classification problem, model performance will be evaluated using:
- Accuracy
- Precision
- Recall
- F1-score
- AUC-ROC
Accuracy gives an overall measure of correct predictions, while Precision, Recall, and F1-score provide a more detailed view of performance for each class. Since the dataset contains three engagement levels, macro and weighted averages will be useful in interpreting the overall classification performance. For AUC-ROC, a one-vs-rest approach will be used to evaluate multiclass performance.
Also, let's create functions to calculate and print the classification report and confusion matrix so that we do not have to rewrite the same code repeatedly for each model.
# Creating metric function
def metrics_score(actual, predicted):
print("Accuracy Score:", metrics.accuracy_score(actual, predicted))
print(classification_report(actual, predicted))
cm = confusion_matrix(actual, predicted)
plt.figure(figsize=(20,12))
sns.heatmap(cm, annot=True, fmt='d',
xticklabels=['Low', 'Medium', 'High'],
yticklabels=['Low', 'Medium', 'High'])
plt.ylabel('Actual')
plt.xlabel('Predicted')
plt.show()
# Function to calculate multiclass AUC-ROC
def auc_roc_score_multiclass(actual, predicted_proba):
auc_score = roc_auc_score(actual, predicted_proba, multi_class='ovr')
print("AUC-ROC Score:", auc_score)
Building the model¶
We will be building 2 different models:
- Decision Tree
- Support Vector Machine (SVM)
Decision Tree Model¶
Decision Tree is a supervised learning algorithm which is used for classification problems where the dependent variable is categorical. In a decision tree, the data is split into branches based on the feature values that best separate the target classes. Here, the dependent variable is player engagement level, with three possible values: Low, Medium, and High.
The model selects the best split at each node, and continues splitting until the stopping criteria are met. The final tree structure is then used to predict the engagement level of players based on their demographic and behavioural features.
Fitting Decision Tree model¶
The purpose of fitting the Decision Tree model is to train it on the training data so that it can learn patterns in player behaviour and use these patterns to predict engagement level.
random_state=1 was used to ensure reproducibility, so that the same results are obtained each time the model is run.
# Fitting Decision Tree model
dtree = DecisionTreeClassifier(random_state = 1)
dtree.fit(X_train, y_train)
DecisionTreeClassifier(random_state=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.
Parameters
| criterion | 'gini' | |
| splitter | 'best' | |
| max_depth | None | |
| min_samples_split | 2 | |
| min_samples_leaf | 1 | |
| min_weight_fraction_leaf | 0.0 | |
| max_features | None | |
| random_state | 1 | |
| max_leaf_nodes | None | |
| min_impurity_decrease | 0.0 | |
| class_weight | None | |
| ccp_alpha | 0.0 | |
| monotonic_cst | None |
Visualising the Training Tree¶
For visualisation purposes, max_depth=2 was applied when plotting the decision tree so that only the most important upper-level splits are shown. This improves interpretability by avoiding an overly large and unreadable full-tree diagram.
plt.figure(figsize=(20, 12))
tree.plot_tree(dtree,
feature_names=X.columns,
class_names=['Low', 'Medium', 'High'],
filled=True,
rounded=True,
max_depth=2)
plt.show()
Training Data Performance¶
y_pred_train_dtree = dtree.predict(X_train)
metrics_score(y_train, y_pred_train_dtree)
auc_roc_score_multiclass(y_train, dtree.predict_proba(X_train))
Accuracy Score: 1.0
precision recall f1-score support
0 1.00 1.00 1.00 7227
1 1.00 1.00 1.00 13561
2 1.00 1.00 1.00 7235
accuracy 1.00 28023
macro avg 1.00 1.00 1.00 28023
weighted avg 1.00 1.00 1.00 28023
AUC-ROC Score: 1.0
Observations:
- The Decision Tree model achieved perfect performance on the training data, with an accuracy, precision, recall, F1-score, and AUC-ROC score of 1.0.
- This indicates that the model classified every training instance correctly.
- However, such perfect performance on the training set may suggest overfitting, meaning the model may have learned the training data too closely rather than generalising well to unseen data.
- Therefore, it is necessary to evaluate the model on the test dataset to determine how well it performs on new data.
Testing Data Performance¶
y_pred_test_dtree = dtree.predict(X_test)
metrics_score(y_test, y_pred_test_dtree)
auc_roc_score_multiclass(y_test, dtree.predict_proba(X_test))
Accuracy Score: 0.8295728915161102
precision recall f1-score support
0 0.79 0.80 0.80 3097
1 0.86 0.85 0.86 5813
2 0.80 0.81 0.81 3101
accuracy 0.83 12011
macro avg 0.82 0.82 0.82 12011
weighted avg 0.83 0.83 0.83 12011
AUC-ROC Score: 0.8665163993692299
Observations:
- The Decision Tree model achieved an accuracy of 0.8295728915161102 (82.96%) on the test data, indicating that it correctly classified the majority of players’ engagement levels.
- The model performed best on the Medium engagement class, with a precision of 0.86, recall of 0.85, and F1-score of 0.86. The Low and High engagement classes also showed reasonably good performance, with F1-scores of 0.80 and 0.81 respectively.
- The AUC-ROC score of 0.8665163993692299 (0.87) indicates good overall class separation. However, compared with the perfect training performance, these results suggest that the Decision Tree model is overfitting, even though it still provides a reasonably strong baseline for predicting player engagement.
Hyperparameter Tuning for Decision Tree¶
Hyperparameter tuning is required because the Decision Tree achieved 100% performance on the training set but lower performance on the test set. This performance gap indicates overfitting, so tuning is needed to make the model less complex and improve generalisation.
The parameters criterion, max_depth, min_samples_split, and min_samples_leaf were tuned to control tree complexity and improve generalisation. GridSearchCV was used to systematically test different parameter combinations, while f1_weighted was selected as the scoring metric because it is suitable for multiclass classification and balances precision and recall.
from sklearn.model_selection import GridSearchCV
# Defining the parameter grid
param_grid = {
'criterion': ['gini', 'entropy'],
'max_depth': [3, 5, 7, 10, None],
'min_samples_split': [2, 5, 10, 20],
'min_samples_leaf': [1, 2, 4, 8]
}
# Creating the Decision Tree model
dtree_tuned = DecisionTreeClassifier(random_state=1)
# Applying GridSearchCV
grid_search_dtree = GridSearchCV(estimator=dtree_tuned,
param_grid=param_grid,
cv=5,
scoring='f1_weighted',
n_jobs=-1)
# Fitting the grid search to the training data
grid_search_dtree.fit(X_train, y_train)
# Printing the best parameters and best score
print("Best Parameters:", grid_search_dtree.best_params_)
print("Best Cross-Validation Score:", grid_search_dtree.best_score_)
Best Parameters: {'criterion': 'gini', 'max_depth': 10, 'min_samples_leaf': 8, 'min_samples_split': 2}
Best Cross-Validation Score: 0.9028909723450014
Observations:
- The best tuned Decision Tree used Gini Index as the split criterion, with a maximum depth of 10, minimum samples per leaf of 8, and minimum samples to split of 2.
- The best cross-validation score was 0.9028909723450014 (0.90) using F1-weighted, indicating strong overall classification performance across the three engagement classes.
- These results suggest that controlling the depth of the tree and increasing the minimum number of samples in each leaf helps reduce overfitting and improve the model’s ability to generalise.
Tuned Decision Tree Performance¶
The tuned Decision Tree model was evaluated on both the training and testing datasets to assess its overall performance and determine whether hyperparameter tuning improved generalisation.
# Best tuned Decision Tree model
best_dtree = grid_search_dtree.best_estimator_
# Training performance
y_pred_train_dtree_tuned = best_dtree.predict(X_train)
metrics_score(y_train, y_pred_train_dtree_tuned)
auc_roc_score_multiclass(y_train, best_dtree.predict_proba(X_train))
# Test performance
y_pred_test_dtree_tuned = best_dtree.predict(X_test)
metrics_score(y_test, y_pred_test_dtree_tuned)
auc_roc_score_multiclass(y_test, best_dtree.predict_proba(X_test))
Accuracy Score: 0.9167112728829889
precision recall f1-score support
0 0.91 0.88 0.90 7227
1 0.92 0.95 0.93 13561
2 0.92 0.89 0.91 7235
accuracy 0.92 28023
macro avg 0.92 0.91 0.91 28023
weighted avg 0.92 0.92 0.92 28023
AUC-ROC Score: 0.9682423176717928
Accuracy Score: 0.9035051203063859
precision recall f1-score support
0 0.90 0.87 0.88 3097
1 0.90 0.93 0.92 5813
2 0.92 0.88 0.90 3101
accuracy 0.90 12011
macro avg 0.90 0.89 0.90 12011
weighted avg 0.90 0.90 0.90 12011
AUC-ROC Score: 0.9365285163534648
Observations:
The tuned Decision Tree model achieved an accuracy of approximately 0.92 on the training data and approximately 0.90 on the test data. This shows strong overall performance and a much smaller gap between training and test results than the untuned model.
The AUC-ROC score was approximately 0.96 on the training set and 0.94 on the test set, indicating very good overall class separation and suggesting that the tuned model generalises well to unseen data.
On the training data, the model produced precision, recall, and F1-scores of around 0.90 to 0.92 across all three engagement classes, showing balanced classification performance.
On the test data, the model also maintained strong and consistent performance across all classes, with F1-scores of approximately 0.88 for Low, 0.92 for Medium, and 0.90 for High engagement. This indicates that the model predicts all three classes effectively, with the Medium engagement class performing best.
The confusion matrices show that most predictions fall on the diagonal, meaning that the majority of players were classified correctly in both the training and testing datasets.
Compared with the untuned Decision Tree, the tuned model appears to show less overfitting, as the training performance is no longer perfect and the test performance remains high. This suggests that hyperparameter tuning improved the model’s ability to generalise.
Overall, the tuned Decision Tree model provides a better balance between training and test performance, making it a stronger and more reliable model for predicting player engagement.
Feature importance for the tuned Decision Tree model¶
Feature importance was analysed to identify which variables contributed most to the tuned Decision Tree model’s predictions. This helps translate the model output into practical business insight by showing which player behaviours or characteristics were most influential when predicting engagement level.
# Checking feature importance for the tuned Decision Tree model
feature_importance = pd.DataFrame({
'Feature': X.columns,
'Importance': best_dtree.feature_importances_
})
# Sorting feature importance values from highest to lowest
feature_importance = feature_importance.sort_values(by='Importance', ascending=False)
# Displaying the top 10 most important features
feature_importance.head(10)
| Feature | Importance | |
|---|---|---|
| 3 | SessionsPerWeek | 0.585923 |
| 4 | AvgSessionDurationMinutes | 0.331179 |
| 5 | PlayerLevel | 0.039280 |
| 6 | AchievementsUnlocked | 0.035409 |
| 1 | PlayTimeHours | 0.003516 |
| 0 | Age | 0.002490 |
| 15 | GameDifficulty_Hard | 0.000386 |
| 13 | GameGenre_Sports | 0.000378 |
| 10 | Location_USA | 0.000376 |
| 7 | Gender_Male | 0.000346 |
# Plotting the top 10 feature importances
plt.figure(figsize=(10, 6))
sns.barplot(data=feature_importance.head(10),
x='Importance',
y='Feature')
plt.title('Top 10 Feature Importances - Tuned Decision Tree')
plt.xlabel('Importance')
plt.ylabel('Feature')
plt.show()
Observations:
The most important feature in the tuned Decision Tree model is SessionsPerWeek, with an importance score of 0.585923. This suggests that how often a player plays each week is the strongest predictor of their engagement level.
The second most important feature is AvgSessionDurationMinutes, with an importance score of 0.331179. This indicates that the average length of a player's gaming sessions is also a major factor in predicting whether their engagement is Low, Medium, or High.
PlayerLevel and AchievementsUnlocked have much smaller importance values, at 0.039280 and 0.035409 respectively. This suggests that player progression contributes to engagement prediction, but not as strongly as session frequency and session duration.
The remaining features, including PlayTimeHours, Age, GameDifficulty_Hard, GameGenre_Sports, Location_USA, and Gender_Male, have very low importance scores. This means they had limited influence on the tuned Decision Tree model’s predictions.
Overall, the feature importance results show that player engagement is mainly driven by behavioural activity patterns, especially how often players play and how long they play for. From a business perspective, the game studio should monitor changes in session frequency and session duration, as these may be useful early indicators of declining or increasing engagement.
Support Vector Machine (SVM)¶
Support Vector Machine (SVM) is a supervised learning algorithm used for classification problems. It works by finding the optimal boundary, known as a hyperplane, that best separates the classes in the dataset.
SVM aims to maximise the margin between the classes, which helps improve generalisation on unseen data. It can be used for both linear and non-linear classification depending on the kernel chosen. In this study, SVM is used to classify player engagement into Low, Medium, and High categories.
Let's build the models using two widely used kernel functions:
- Linear Kernel
- RBF Kernel
Linear Kernel¶
The linear SVM model was trained using the scaled training dataset. A linear kernel creates a straight-line decision boundary and is useful as a baseline model for checking whether the engagement classes can be separated using a simpler classification approach.
# Fitting SVM with Linear Kernel
svm = SVC(kernel='linear') # Linear kernel or linear decision boundary
# Fit the model
model = svm.fit(X=X_train_scaled, y=y_train)
Checking the performance on the training data¶
# Checking performance on the training data
y_pred_train_svm = model.predict(X_train_scaled)
metrics_score(y_train, y_pred_train_svm)
Accuracy Score: 0.8246083574206902
precision recall f1-score support
0 0.79 0.71 0.75 7227
1 0.81 0.87 0.84 13561
2 0.89 0.85 0.87 7235
accuracy 0.82 28023
macro avg 0.83 0.81 0.82 28023
weighted avg 0.83 0.82 0.82 28023
Checking the performance on the test data¶
# Checking performance on the test data
y_pred_test_svm = model.predict(X_test_scaled)
metrics_score(y_test, y_pred_test_svm)
Accuracy Score: 0.8234118724502539
precision recall f1-score support
0 0.79 0.71 0.75 3097
1 0.81 0.87 0.84 5813
2 0.89 0.85 0.87 3101
accuracy 0.82 12011
macro avg 0.83 0.81 0.82 12011
weighted avg 0.82 0.82 0.82 12011
Observations:
The SVM model with a linear kernel achieved an accuracy of 0.8246083574206902 (82.46%) on the training data and 0.8234118724502539 (82.34%) on the test data. The results are very similar, which suggests that the model is generalising well and is not overfitting.
On the training data, the model produced precision, recall, and F1-scores in the range of approximately 0.75 to 0.89, with the strongest performance seen for the High engagement class and the weakest recall for the Low engagement class.
On the test data, the model maintained almost the same level of performance, with F1-scores of 0.75 for Low, 0.84 for Medium, and 0.87 for High engagement. This indicates that the model performs consistently on unseen data.
The confusion matrices for both training and test data show that most predictions fall on the diagonal, meaning a large proportion of players were correctly classified. However, a noticeable number of Low engagement players were misclassified as Medium, and some High engagement players were also predicted as Medium, suggesting overlap between neighbouring engagement levels.
Overall, the very small difference between the training and test results suggests that the linear SVM model provides consistent and reliable performance. However, its overall performance is lower than the tuned Decision Tree and tuned RBF SVM models, which suggests that a non-linear model may be better suited to this dataset.
RBF Kernal¶
For the precision-recall threshold analysis, the target variable was converted from multiclass to a one-vs-rest binary format. This was necessary because precision_recall_curve() only supports binary classification. In this case, Low engagement was treated as the positive class, while Medium and High were grouped together as the negative class. This approach makes it possible to follow the same process working with a multiclass dataset.
# RBF Kernel
svm_rbf = SVC(kernel='rbf', probability=True, random_state=1)
# Fit the model
svm_rbf.fit(X_train_scaled, y_train)
# Predict probabilities on the training data
y_scores_svm = svm_rbf.predict_proba(X_train_scaled)
# precision_recall_curve() only works for binary classification.
# Since this dataset has three classes (Low, Medium, High),
# the target must be converted to a one-vs-rest binary format.
# Here:
# 1 = Low engagement
# 0 = Medium or High engagement
y_train_low_vs_rest = (y_train == 0).astype(int)
# Find the probability column for the Low engagement class
low_class_index = list(svm_rbf.classes_).index(0)
# Calculate precision, recall, and thresholds for Low vs Rest
precisions_svm, recalls_svm, thresholds_svm = precision_recall_curve(
y_train_low_vs_rest,
y_scores_svm[:, low_class_index]
)
# Plot values of precision, recall, and thresholds
plt.figure(figsize=(10,7))
plt.plot(thresholds_svm, precisions_svm[:-1], 'b--', label='precision')
plt.plot(thresholds_svm, recalls_svm[:-1], 'g--', label='recall')
plt.xlabel('Threshold')
plt.title('Precision-Recall Threshold Curve for Low Engagement vs Rest')
plt.legend(loc='upper left')
plt.ylim([0,1])
plt.show()
Obersevations
- We can see that precision and recall are balanced for a threshold of about ~0.45.
# Choosing a threshold from the graph
optimal_threshold_svm = 0.45
# Applying the threshold on the training data for Low vs Rest
y_pred_train_low = (y_scores_svm[:, low_class_index] > optimal_threshold_svm).astype(int)
print(classification_report(y_train_low_vs_rest, y_pred_train_low))
cm = confusion_matrix(y_train_low_vs_rest, y_pred_train_low)
plt.figure(figsize=(8,5))
sns.heatmap(cm, annot=True, fmt='d',
xticklabels=['Not Low', 'Low'],
yticklabels=['Not Low', 'Low'])
plt.ylabel('Actual')
plt.xlabel('Predicted')
plt.show()
precision recall f1-score support
0 0.96 0.96 0.96 20796
1 0.89 0.88 0.89 7227
accuracy 0.94 28023
macro avg 0.93 0.92 0.92 28023
weighted avg 0.94 0.94 0.94 28023
# Converting the test target to Low vs Rest
y_test_low_vs_rest = (y_test == 0).astype(int)
# Predict probabilities on the test data
y_scores_svm_test = svm_rbf.predict_proba(X_test_scaled)
# Applying the same threshold on the test data
y_pred_test_low = (y_scores_svm_test[:, low_class_index] > optimal_threshold_svm).astype(int)
print(classification_report(y_test_low_vs_rest, y_pred_test_low))
cm = confusion_matrix(y_test_low_vs_rest, y_pred_test_low)
plt.figure(figsize=(8,5))
sns.heatmap(cm, annot=True, fmt='d',
xticklabels=['Not Low', 'Low'],
yticklabels=['Not Low', 'Low'])
plt.ylabel('Actual')
plt.xlabel('Predicted')
plt.show()
precision recall f1-score support
0 0.95 0.95 0.95 8914
1 0.86 0.85 0.86 3097
accuracy 0.93 12011
macro avg 0.91 0.90 0.90 12011
weighted avg 0.93 0.93 0.93 12011
Observations:
The precision-recall curve was produced using a Low engagement vs Rest approach because the original target variable has three classes: Low, Medium, and High. In this binary version, Low engagement is treated as the positive class, while Medium and High are grouped together as Not Low.
A threshold of 0.45 was selected from the precision-recall curve because this is close to where precision and recall become balanced. This provides a more even trade-off between correctly identifying Low engagement players and reducing false positives.
On the training data, the RBF SVM threshold model achieved an overall accuracy of 0.94. For the Low engagement class, the model achieved precision of 0.89, recall of 0.88, and an F1-score of 0.89.
The training confusion matrix shows that the model correctly identified 6345 Low engagement players, while 882 Low engagement players were incorrectly predicted as Not Low. This means the model still captures most low-engagement players, although some are missed.
The training confusion matrix also shows 20031 correct Not Low predictions, while 765 Not Low players were incorrectly predicted as Low.
On the test data, the model achieved an overall accuracy of 0.93, showing that it generalised well to unseen data. For the Low engagement class, the model achieved precision of 0.86, recall of 0.85, and an F1-score of 0.86.
The test confusion matrix shows that 2637 Low engagement players were correctly identified, while 460 Low engagement players were incorrectly predicted as Not Low. This indicates good performance in identifying low-engagement players on unseen data.
The test confusion matrix also shows 8494 correct Not Low predictions, while 420 Not Low players were incorrectly predicted as Low. This shows that the model has reduced false positives while still maintaining strong recall for the Low engagement class.
Overall, the RBF SVM threshold model performs well at the selected threshold of 0.45. The model provides a balanced result, with strong accuracy, precision, recall, and F1-score on both training and testing data. This makes it useful for identifying players who may be at risk of low engagement while avoiding excessive false positive predictions.
Hyperparameter tuning for the SVM models¶
Hyperparameter tuning was applied to the SVM models to identify the best combination of parameters for predicting player engagement. The parameters C, kernel, and gamma were tuned because they control the flexibility of the decision boundary and the model’s ability to generalise to unseen data.
A reduced hyperparameter grid was used because SVM models can be computationally expensive on large datasets. This allows the model to test the main linear and non-linear options while keeping the tuning process practical.
The reduced grid tests both a linear SVM and RBF SVM. The linear kernel is included as a simpler baseline, while the RBF kernel is tested with different C and gamma values to explore whether a non-linear decision boundary improves classification performance.
from sklearn.model_selection import GridSearchCV
# Defining a reduced parameter grid for SVM
# A smaller grid was used because SVM can be computationally expensive on a large dataset.
param_grid_svm = [
{
'kernel': ['linear'],
'C': [1]
},
{
'kernel': ['rbf'],
'C': [1, 10],
'gamma': ['scale', 0.1]
}
]
# Creating SVM model
svm_tuned = SVC(random_state=1)
# Applying GridSearchCV
grid_search_svm = GridSearchCV(estimator=svm_tuned,
param_grid=param_grid_svm,
cv=3,
scoring='f1_weighted',
n_jobs=-1)
# Fitting the grid search to the scaled training data
grid_search_svm.fit(X_train_scaled, y_train)
# Printing the best parameters and best score
print("Best Parameters:", grid_search_svm.best_params_)
print("Best Cross-Validation Score:", grid_search_svm.best_score_)
Best Parameters: {'C': 1, 'gamma': 'scale', 'kernel': 'rbf'}
Best Cross-Validation Score: 0.8780220877471855
Observations:
The best SVM parameters identified through GridSearchCV were C = 1, gamma = scale, and kernel = rbf.
This indicates that the RBF kernel performed better than the linear kernel in the reduced hyperparameter grid, suggesting that player engagement is better separated using a non-linear decision boundary.
The best cross-validation score was 0.8780220877471855 (0.88) using F1-weighted, indicating strong overall performance across the three engagement classes.
Overall, the tuning process suggests that the RBF SVM is the stronger SVM option for this dataset.
Now, lets create the best tuned SVM model
The best SVM parameters identified during GridSearchCV were used to create the final tuned SVM model. The model was refitted with probability=True so that predicted probabilities could be generated for the multiclass AUC-ROC calculation.
# Creating the best SVM model with probability=True for AUC-ROC
best_svm = SVC(**grid_search_svm.best_params_,
probability=True,
random_state=1)
# Fitting the best SVM model
best_svm.fit(X_train_scaled, y_train)
SVC(C=1, probability=True, random_state=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.
Parameters
| C | 1 | |
| kernel | 'rbf' | |
| degree | 3 | |
| gamma | 'scale' | |
| coef0 | 0.0 | |
| shrinking | True | |
| probability | True | |
| tol | 0.001 | |
| cache_size | 200 | |
| class_weight | None | |
| verbose | False | |
| max_iter | -1 | |
| decision_function_shape | 'ovr' | |
| break_ties | False | |
| random_state | 1 |
Evaluating the tuned SVM model¶
The tuned SVM model was evaluated on both the training and testing datasets to assess its performance and compare how well it generalises to unseen data.
# Checking the performance on the training data
y_pred_train_svm_tuned = best_svm.predict(X_train_scaled)
metrics_score(y_train, y_pred_train_svm_tuned)
auc_roc_score_multiclass(y_train, best_svm.predict_proba(X_train_scaled))
Accuracy Score: 0.9058273561003461
precision recall f1-score support
0 0.91 0.85 0.88 7227
1 0.89 0.95 0.92 13561
2 0.93 0.87 0.90 7235
accuracy 0.91 28023
macro avg 0.91 0.89 0.90 28023
weighted avg 0.91 0.91 0.91 28023
AUC-ROC Score: 0.950949445110719
# Checking the performance on the test data
y_pred_test_svm_tuned = best_svm.predict(X_test_scaled)
metrics_score(y_test, y_pred_test_svm_tuned)
auc_roc_score_multiclass(y_test, best_svm.predict_proba(X_test_scaled))
Accuracy Score: 0.8850220631088169
precision recall f1-score support
0 0.89 0.82 0.86 3097
1 0.87 0.93 0.90 5813
2 0.92 0.85 0.88 3101
accuracy 0.89 12011
macro avg 0.89 0.87 0.88 12011
weighted avg 0.89 0.89 0.88 12011
AUC-ROC Score: 0.9396443476549677
Observations:
The tuned SVM model achieved an accuracy of 0.9058273561003461 (0.91) on the training data and 0.8850229631088169 (0.89) on the test data. The small difference between training and testing performance suggests that the model generalises well and is not heavily overfitting.
On the training data, the model performed strongly across all three engagement classes. The Medium engagement class achieved the highest recall at 0.95, meaning the model was particularly effective at identifying Medium engagement players.
On the test data, the model maintained strong overall performance, with F1-scores of 0.86 for Low, 0.90 for Medium, and 0.88 for High engagement. This shows that the model performs consistently across the three classes.
The training confusion matrix shows that the model correctly classified 6167 Low, 12901 Medium, and 6316 High engagement players. Most predictions fall along the diagonal, indicating strong classification performance.
The test confusion matrix also shows strong results, with 2555 Low, 5435 Medium, and 2640 High engagement players correctly classified. The main errors occur between neighbouring classes, especially Low being predicted as Medium and High being predicted as Medium.
The AUC-ROC score was 0.9509494451107119 (0.95) on the training data and 0.9396443476549677 (0.94) on the test data. This indicates very good class separation and suggests that the tuned SVM model is effective at distinguishing between engagement levels.
Overall, the tuned SVM model provides strong and stable performance. It does not achieve perfect training results, which suggests it is less prone to overfitting, while still performing well on unseen test data.
Model comparison and best supervised model¶
The tuned Decision Tree and tuned SVM models were compared to identify which supervised model performed best for predicting player engagement. The comparison focuses on test performance because the test dataset represents unseen data and gives a better indication of how well each model generalises.
# Comparing the best supervised models
model_comparison = pd.DataFrame({
'Model': ['Tuned Decision Tree', 'Tuned SVM'],
'Test Accuracy': [
metrics.accuracy_score(y_test, y_pred_test_dtree_tuned),
metrics.accuracy_score(y_test, y_pred_test_svm_tuned)
],
'Test Precision Weighted': [
metrics.precision_score(y_test, y_pred_test_dtree_tuned, average='weighted'),
metrics.precision_score(y_test, y_pred_test_svm_tuned, average='weighted')
],
'Test Recall Weighted': [
metrics.recall_score(y_test, y_pred_test_dtree_tuned, average='weighted'),
metrics.recall_score(y_test, y_pred_test_svm_tuned, average='weighted')
],
'Test F1 Weighted': [
metrics.f1_score(y_test, y_pred_test_dtree_tuned, average='weighted'),
metrics.f1_score(y_test, y_pred_test_svm_tuned, average='weighted')
],
'Test AUC-ROC': [
roc_auc_score(y_test, best_dtree.predict_proba(X_test), multi_class='ovr'),
roc_auc_score(y_test, best_svm.predict_proba(X_test_scaled), multi_class='ovr')
]
})
model_comparison
| Model | Test Accuracy | Test Precision Weighted | Test Recall Weighted | Test F1 Weighted | Test AUC-ROC | |
|---|---|---|---|---|---|---|
| 0 | Tuned Decision Tree | 0.903505 | 0.903606 | 0.903505 | 0.903285 | 0.936529 |
| 1 | Tuned SVM | 0.885022 | 0.886322 | 0.885022 | 0.884445 | 0.939644 |
# Selecting the best model based on weighted F1-score
best_model_name = model_comparison.sort_values(
by='Test F1 Weighted',
ascending=False
).iloc[0]['Model']
print("Best supervised model based on weighted F1-score:", best_model_name)
Best supervised model based on weighted F1-score: Tuned Decision Tree
Observations:
The tuned Decision Tree and tuned SVM both performed strongly on the test dataset, showing that both models were suitable for predicting player engagement.
The tuned Decision Tree achieved slightly stronger overall test performance based on accuracy and weighted F1-score. This suggests that it was better at balancing performance across the Low, Medium, and High engagement classes.
The tuned SVM also performed well and produced a strong AUC-ROC score, showing good class separation. However, its overall test accuracy and weighted F1-score were slightly lower than the tuned Decision Tree.
The tuned Decision Tree also has an additional advantage because it provides feature importance, making it easier to interpret which player behaviours influenced engagement predictions.
Overall, the tuned Decision Tree was selected as the best supervised model because it provided the strongest balance of predictive performance, generalisation, and interpretability.
Clustering¶
K-Means clustering was applied to uncover hidden player segments based on behavioural and demographic features. Unlike the supervised models, clustering does not use the target variable EngagementLevel. Instead, it groups players based on similarity across features such as age, play time, sessions per week, session duration, player level, achievements unlocked, location, gender, game genre, in-game purchases, and game difficulty.
Import Clustering Libraries¶
# Importing clustering libraries
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
Prepare the data for clustering¶
# Preparing data for clustering
# EngagementLevel is removed because clustering is unsupervised
# The aim is to discover hidden player segments without using the target label
cluster_data = df.drop(columns=['EngagementLevel'])
# Scaling the clustering data
# K-Means uses distance-based calculations, so scaling is required
sc_cluster = StandardScaler()
cluster_data_scaled = sc_cluster.fit_transform(cluster_data)
Find the best number of clusters using inertia and sampled silhouette score¶
A sample was used for the silhouette score because calculating it on the full dataset can be computationally expensive. This does not affect the K-Means clustering model or the inertia values, as the clusters are still fitted using the full dataset; it only provides a faster estimate of cluster separation.
# Finding the optimal number of clusters using inertia and sampled silhouette score
inertia_values = []
silhouette_scores = []
k_values = range(2, 11)
for k in k_values:
kmeans = KMeans(n_clusters=k, random_state=1, n_init=10)
cluster_labels = kmeans.fit_predict(cluster_data_scaled)
# Inertia is calculated using the full dataset
inertia_values.append(kmeans.inertia_)
# Silhouette score is calculated using a sample to reduce processing time
silhouette_scores.append(
silhouette_score(cluster_data_scaled,
cluster_labels,
sample_size=5000,
random_state=1)
)
# Displaying the results
cluster_results = pd.DataFrame({
'Number of Clusters': list(k_values),
'Inertia': inertia_values,
'Silhouette Score': silhouette_scores
})
cluster_results
| Number of Clusters | Inertia | Silhouette Score | |
|---|---|---|---|
| 0 | 2 | 626213.239244 | 0.076699 |
| 1 | 3 | 587156.824349 | 0.107668 |
| 2 | 4 | 549504.639498 | 0.120195 |
| 3 | 5 | 520386.717065 | 0.106855 |
| 4 | 6 | 491310.700688 | 0.113239 |
| 5 | 7 | 477748.071240 | 0.096099 |
| 6 | 8 | 466964.103604 | 0.100742 |
| 7 | 9 | 457307.058341 | 0.096369 |
| 8 | 10 | 446928.706116 | 0.102801 |
Observations:
The silhouette score is highest at 4 clusters, with a value of 0.120195. This suggests that, out of the tested options, 4 clusters provides the best separation between player groups.
The silhouette scores are quite low overall, ranging from 0.076699 to 0.120195, which indicates that the player segments are not strongly separated. This suggests some overlap between player behaviours across clusters.
Plot the elbow curve¶
# Plotting the elbow curve using inertia
plt.figure(figsize=(8,5))
plt.plot(k_values, inertia_values, marker='o')
plt.xlabel('Number of Clusters')
plt.ylabel('Inertia')
plt.title('Elbow Method for Choosing Number of Clusters')
plt.show()
Observations:
The elbow curve shows that inertia decreases as the number of clusters increases, from 626213.239244 at 2 clusters to 446928.706116 at 10 clusters. This is expected because adding more clusters reduces the distance between data points and their assigned cluster centres.
The elbow plot does not show a very sharp elbow point, although the rate of decrease appears to slow after around 5 to 6 clusters. This suggests that adding more clusters continues to improve compactness, but the improvement becomes smaller.
Plot the silhouette scores¶
# Plotting silhouette scores
plt.figure(figsize=(8,5))
plt.plot(k_values, silhouette_scores, marker='o')
plt.xlabel('Number of Clusters')
plt.ylabel('Silhouette Score')
plt.title('Silhouette Score for Different Numbers of Clusters')
plt.show()
Observations:
- Overall, 4 clusters appears to be the most suitable choice based on the silhouette score. However, the scores are quite low overall, which suggests that the clusters are not strongly separated and should be interpreted as broad player segments rather than clearly distinct player types.
Hyperparameter tuning for K-Means clustering¶
Hyperparameter tuning was applied to the K-Means clustering model to identify the best combination of parameters for uncovering player segments. Since clustering is unsupervised, there is no target variable.
# Hyperparameter tuning for K-Means clustering
from sklearn.model_selection import ParameterGrid
# Defining parameter grid for K-Means
param_grid_kmeans = {
'n_clusters': [3, 4, 5, 6],
'init': ['k-means++', 'random'],
'n_init': [10, 20],
'max_iter': [300, 500]
}
# Storing results
kmeans_tuning_results = []
# Testing each parameter combination
for params in ParameterGrid(param_grid_kmeans):
kmeans_model = KMeans(
n_clusters=params['n_clusters'],
init=params['init'],
n_init=params['n_init'],
max_iter=params['max_iter'],
random_state=1
)
cluster_labels = kmeans_model.fit_predict(cluster_data_scaled)
inertia = kmeans_model.inertia_
silhouette = silhouette_score(
cluster_data_scaled,
cluster_labels,
sample_size=5000,
random_state=1
)
kmeans_tuning_results.append({
'n_clusters': params['n_clusters'],
'init': params['init'],
'n_init': params['n_init'],
'max_iter': params['max_iter'],
'Inertia': inertia,
'Silhouette Score': silhouette
})
# Converting results to DataFrame
kmeans_tuning_results_df = pd.DataFrame(kmeans_tuning_results)
# Sorting by Silhouette Score
kmeans_tuning_results_df = kmeans_tuning_results_df.sort_values(
by='Silhouette Score',
ascending=False
)
kmeans_tuning_results_df
| n_clusters | init | n_init | max_iter | Inertia | Silhouette Score | |
|---|---|---|---|---|---|---|
| 29 | 5 | random | 20 | 500 | 513958.185611 | 0.132229 |
| 21 | 5 | random | 20 | 300 | 513958.185611 | 0.132229 |
| 3 | 4 | k-means++ | 20 | 300 | 545438.171611 | 0.126820 |
| 11 | 4 | k-means++ | 20 | 500 | 545438.171611 | 0.126820 |
| 19 | 4 | random | 20 | 300 | 545438.171611 | 0.126820 |
| 27 | 4 | random | 20 | 500 | 545438.171611 | 0.126820 |
| 26 | 4 | random | 10 | 500 | 549581.478975 | 0.121152 |
| 18 | 4 | random | 10 | 300 | 549581.478975 | 0.121152 |
| 10 | 4 | k-means++ | 10 | 500 | 549504.639498 | 0.120195 |
| 2 | 4 | k-means++ | 10 | 300 | 549504.639498 | 0.120195 |
| 15 | 6 | k-means++ | 20 | 500 | 491310.700688 | 0.113239 |
| 14 | 6 | k-means++ | 10 | 500 | 491310.700688 | 0.113239 |
| 6 | 6 | k-means++ | 10 | 300 | 491310.700688 | 0.113239 |
| 7 | 6 | k-means++ | 20 | 300 | 491310.700688 | 0.113239 |
| 13 | 5 | k-means++ | 20 | 500 | 519637.748598 | 0.112695 |
| 5 | 5 | k-means++ | 20 | 300 | 519637.748598 | 0.112695 |
| 31 | 6 | random | 20 | 500 | 492558.476329 | 0.112636 |
| 23 | 6 | random | 20 | 300 | 492558.476329 | 0.112636 |
| 30 | 6 | random | 10 | 500 | 493096.036372 | 0.110059 |
| 22 | 6 | random | 10 | 300 | 493096.036372 | 0.110059 |
| 20 | 5 | random | 10 | 300 | 520202.780832 | 0.109989 |
| 28 | 5 | random | 10 | 500 | 520202.780832 | 0.109989 |
| 8 | 3 | k-means++ | 10 | 500 | 587156.824349 | 0.107668 |
| 0 | 3 | k-means++ | 10 | 300 | 587156.824349 | 0.107668 |
| 4 | 5 | k-means++ | 10 | 300 | 520386.717065 | 0.106855 |
| 12 | 5 | k-means++ | 10 | 500 | 520386.717065 | 0.106855 |
| 17 | 3 | random | 20 | 300 | 582852.794656 | 0.105370 |
| 16 | 3 | random | 10 | 300 | 582852.794656 | 0.105370 |
| 24 | 3 | random | 10 | 500 | 582852.794656 | 0.105370 |
| 25 | 3 | random | 20 | 500 | 582852.794656 | 0.105370 |
| 9 | 3 | k-means++ | 20 | 500 | 583484.105465 | 0.097905 |
| 1 | 3 | k-means++ | 20 | 300 | 583484.105465 | 0.097905 |
Select best K-Means Parameters¶
# Selecting the best K-Means parameters
best_kmeans_params = kmeans_tuning_results_df.iloc[0]
print("Best K-Means Parameters:")
print(best_kmeans_params)
Best K-Means Parameters: n_clusters 5 init random n_init 20 max_iter 500 Inertia 513958.185611 Silhouette Score 0.132229 Name: 29, dtype: object
Build the final tuned K-Means Model¶
Initial silhouette analysis suggested 4 clusters, but the later hyperparameter tuning process tested additional K-Means settings and selected 5 clusters because this configuration produced the highest silhouette score. Therefore, the final tuned K-Means model used 5 clusters.
# Building the final tuned K-Means model
best_kmeans = KMeans(
n_clusters=int(best_kmeans_params['n_clusters']),
init=best_kmeans_params['init'],
n_init=int(best_kmeans_params['n_init']),
max_iter=int(best_kmeans_params['max_iter']),
random_state=1
)
# Adding tuned cluster labels to the dataframe
df['Tuned_Cluster'] = best_kmeans.fit_predict(cluster_data_scaled)
# Checking cluster sizes
df['Tuned_Cluster'].value_counts()
Tuned_Cluster 3 14412 1 7273 0 7226 2 7188 4 3935 Name: count, dtype: int64
Analysis on feature averages by tuned cluster¶
# Analysing numerical feature averages by tuned cluster
tuned_cluster_profile_num = df.groupby('Tuned_Cluster')[num_cols].mean()
tuned_cluster_profile_num
| Age | PlayTimeHours | SessionsPerWeek | AvgSessionDurationMinutes | PlayerLevel | AchievementsUnlocked | |
|---|---|---|---|---|---|---|
| Tuned_Cluster | ||||||
| 0 | 31.976197 | 12.082769 | 9.503737 | 95.688486 | 49.901744 | 24.639773 |
| 1 | 32.039186 | 11.976763 | 9.500344 | 95.101471 | 49.938815 | 24.440671 |
| 2 | 32.161380 | 11.885782 | 9.469950 | 94.635364 | 49.057179 | 24.466472 |
| 3 | 31.901193 | 12.078230 | 9.450250 | 94.223702 | 49.543922 | 24.644463 |
| 4 | 31.962389 | 12.060967 | 9.442440 | 94.943837 | 50.181957 | 24.154511 |
Observations:
The tuned clusters have very similar average values across the numerical features. For example, average age ranges only from 31.901193 to 32.161380, while average play time ranges from 11.885782 to 12.087269 hours. This suggests that the clusters are not strongly separated by the numerical variables alone.
The biggest differences are still quite small, but Cluster 4 has the highest average PlayerLevel at 50.181957, while Cluster 2 has the lowest average PlayerLevel at 49.057179. Overall, the cluster averages suggest that player segmentation may be influenced more by the encoded categorical features or combinations of variables rather than clear differences in individual numerical averages.
# Checking engagement level distribution by tuned cluster
pd.crosstab(df['Tuned_Cluster'], df['EngagementLevel'], normalize='index') * 100
| EngagementLevel | 0 | 1 | 2 |
|---|---|---|---|
| Tuned_Cluster | |||
| 0 | 25.256020 | 48.796014 | 25.947966 |
| 1 | 25.601540 | 48.329438 | 26.069022 |
| 2 | 26.182526 | 47.398442 | 26.419032 |
| 3 | 25.804885 | 48.827366 | 25.367749 |
| 4 | 26.327827 | 48.005083 | 25.667090 |
Observations:
The tuned K-Means model produced 5 clusters, with Cluster 3 being the largest group at 14412 players, while Cluster 4 was the smallest group at 3935 players. This shows that the player base has been segmented into groups of different sizes.
The engagement distribution is broadly similar across the clusters. In every cluster, Medium engagement is the largest group, ranging from approximately 47.398442% to 48.827366%. Low and High engagement players are also fairly evenly distributed across clusters.
Cluster 2 has the highest proportion of High engagement players at 26.419032%, while Cluster 4 has the highest proportion of Low engagement players at 26.327827%. However, these differences are small, suggesting that the clusters do not map strongly onto engagement level alone.
Overall, the clustering results suggest that the player segments are broad rather than sharply distinct. The clusters may be influenced more by combinations of behavioural and categorical features rather than clear differences in individual numerical averages or engagement level.
K-Means cluster interpretation¶
The clustering results were interpreted alongside the silhouette score, inertia values, numerical cluster profiles, and engagement distribution. This is important because K-Means does not use the target variable during training, so the clusters need to be evaluated based on both statistical separation and practical business meaning.
Observations:
The tuned K-Means model segmented the player base into 5 clusters, allowing the dataset to be explored from an unsupervised learning perspective.
The numerical averages across the clusters are very similar, suggesting that the player groups are not strongly separated by individual numerical features such as age, play time, session frequency, player level, or achievements unlocked. This supports the earlier silhouette score results, which showed relatively weak cluster separation.
The engagement distribution is also broadly similar across clusters, with Medium engagement being the largest group in each cluster. This suggests that the K-Means clusters do not directly map onto the original Low, Medium, and High engagement labels.
However, this does not mean the clustering is useless. Instead, it suggests that player engagement is likely influenced by combinations of features rather than one or two obvious numerical variables. The clusters may therefore represent broad behavioural patterns rather than clearly separate player types.
From a business perspective, clusters with a slightly higher proportion of Low engagement players could be used for further retention analysis. These players may benefit from targeted interventions such as personalised rewards, difficulty adjustments, reminders, or content recommendations.
Clusters with a slightly higher proportion of High engagement players may help the game studio identify behaviours linked with stronger involvement. These patterns could inform future game design decisions, such as encouraging similar play habits or progression pathways among less engaged players.
Overall, the K-Means results should be interpreted cautiously. The clustering provides useful exploratory segmentation, but the low silhouette scores and similar cluster profiles suggest that the segments are broad rather than strongly distinct. Therefore, the clustering output is most useful when combined with the supervised models, which provide stronger individual-level engagement predictions.
Comparison of supervised and unsupervised learning insights¶
Observations:
The supervised learning models were more effective for predicting individual player engagement levels because they used the known EngagementLevel target variable during training. This allowed the Decision Tree and SVM models to learn patterns associated with Low, Medium, and High engagement.
The tuned Decision Tree and tuned SVM both performed strongly on the test data. However, the tuned Decision Tree was selected as the best supervised model because it gave the strongest overall balance of predictive performance, generalisation, and interpretability.
The tuned SVM also performed well and achieved strong generalisation and AUC-ROC performance, making it a useful comparison model. However, it was less interpretable than the Decision Tree because it does not provide feature importance in the same clear way.
The Decision Tree feature importance results showed that SessionsPerWeek and AvgSessionDurationMinutes were the most influential predictors of engagement. This suggests that how often players play and how long they play for are stronger indicators of engagement than demographic variables such as age, gender, or location.
The unsupervised K-Means model provided a different type of insight. Instead of predicting engagement level directly, it grouped players into 5 clusters based on similarities in demographic and behavioural features.
However, the K-Means results showed that the clusters were broad rather than strongly distinct. The numerical averages and engagement distributions were similar across clusters, which suggests that player segments do not map neatly onto the original engagement categories.
Overall, the tuned Decision Tree is the most useful model for predicting individual engagement level, while K-Means clustering is more useful for exploring broad player segments and identifying groups for further business analysis.
Practical business recommendations¶
Recommendations:
The game studio should use the tuned Decision Tree model to identify players who are likely to fall into the Low engagement category. These players could be targeted with retention strategies before they become inactive.
Since SessionsPerWeek was the strongest predictor of engagement, the studio should monitor changes in how often players return to the game. A drop in weekly sessions may be an early warning sign of declining engagement.
Since AvgSessionDurationMinutes was also highly important, the studio should monitor session length. Shorter sessions may suggest that players are losing interest, finding the game too difficult, or not progressing quickly enough.
For players predicted as Low engagement, the studio could use targeted interventions such as personalised rewards, login bonuses, easier progression routes, reminders, or tailored content recommendations.
For players with High engagement, the studio could focus on maintaining loyalty through advanced challenges, achievement-based rewards, competitive events, or exclusive content.
K-Means clusters should be used as broad player segments rather than precise engagement labels. These clusters could help the studio explore different player groups and compare how they respond to promotions, game difficulty, or new content.
The strongest overall approach would be to combine both methods: use the tuned Decision Tree for individual engagement prediction and use K-Means clustering to support wider player segmentation and marketing strategy.
Limitations¶
Limitations:
The K-Means clusters were not strongly separated, so they should be interpreted cautiously and used as broad exploratory segments rather than fixed player types.
The dataset appears to show strong behavioural patterns around session frequency and session duration, but real-world player behaviour may change over time. Therefore, the models should be retrained and monitored regularly.
The analysis is based on the variables available in the dataset. Additional features such as player churn history, spending value, game mode preference, device type, or social interaction data could improve future prediction and segmentation.