In [1]:
# Sian Kiely 2218899 December 2025 Artificial intelligence and Machine Learning ACCA6011 Assignment – ML Model
In [2]:
# [1] https://www.kaggle.com/datasets/rabieelkharoua/predict-online-gaming-behavior-dataset?resource=download

# Data analytics team at game development studio. Goal: better understand and engage player base
# Using Predict Online Gaming Behaviour Dataset from Kaggle [1] look at target variable, EnagementLevel: high, medium, log
# Player demographics: Age, Gender, location. Player Habits: session frequency, in game purchases, playtime
# Aim: apply and evaluate decision tree and support vector machine (SVM) models to predict player engagement
# Use unsupervised learning techniques to discover behaviousal patterns among gamers in online environments
In [3]:
# Import necessary libraries

import pandas as pd
import numpy as np

from sklearn.model_selection import train_test_split, GridSearchCV, StratifiedKFold # Ref
from sklearn.preprocessing import StandardScaler, label_binarize # Ref 
from sklearn.pipeline import Pipeline # need to remove, pipeline removed 
from sklearn.svm import SVC

from sklearn.metrics import (accuracy_score, classification_report, confusion_matrix, roc_auc_score, precision_score, recall_score, f1_score)

from sklearn import tree
from sklearn.tree import DecisionTreeClassifier

#from sklearn.metrics import average_precision_score # tried a workaround for the non-binary engagemeny level: can now remove this

import matplotlib.pyplot as plt
import seaborn as sns

# Was getting a weird 'error' in grid_svm, n_jobs1 for runtime on windows
import os
os.environ["LOKY_MAX_CPU_COUNT"] = "1"
In [4]:
# Personal trouble shooting
# Habit of typing randome instead of random, ctrl F to search and correct, m instead of , is another common typo
# Using capital Y instead of y which seems non standard but typed most of it before realising 
# Importing in too many places, using main data set and changing it too much, errors dropping columns etc 
# create copies of 'raw' per section instead and fresh import
In [5]:
#
# Data exploration and preprocessing
# 
In [6]:
# load the dataset as csv # CHANGE FILE PATH HERE if viewing on a different PC
raw= pd.read_csv('C:/Users/siane/anaconda_projects/online_gaming_behavior_dataset.csv')
data = raw.copy()
In [7]:
# View headers, row column totals, start and end examples
data.head()
data.tail()
Out[7]:
PlayerID Age Gender Location GameGenre PlayTimeHours InGamePurchases GameDifficulty SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked EngagementLevel
40029 49029 32 Male USA Strategy 20.619662 0 Easy 4 75 85 14 Medium
40030 49030 44 Female Other Simulation 13.539280 0 Hard 19 114 71 27 High
40031 49031 15 Female USA RPG 0.240057 1 Easy 10 176 29 1 High
40032 49032 34 Male USA Sports 14.017818 1 Medium 3 128 70 10 Medium
40033 49033 19 Male USA Sports 10.083804 0 Easy 13 84 72 39 Medium
In [8]:
# View useful info such as Dtype (datatype) and non-null (empty cell) counts, this data set should have no nulls
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
In [9]:
# There are 40034 observations and 13 columns in the data set
# No missing values in the data (non-null values)

# Check for unique values per column: player ID for example is an identifier, no insight in machine learning can be gained. Drop this column
data.nunique()
Out[9]:
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
In [10]:
# Displays the hearer and some rows in a clear format
data.head()
Out[10]:
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
In [11]:
# Some additional basic checks

print(data.shape) # print dimensions of data frame: rows, columns
print(data.isna().sum().sort_values(ascending=False).head(15)) # isna() checks for missing values, counts them with sum(), sorts descending, top missing
display(data.describe(include='all').transpose().head()) 
# shows descriptive stats for all columns. Transposes columns as rows, first few rows of stat summary
(40034, 13)
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
count unique top freq mean std min 25% 50% 75% max
PlayerID 40034.0 NaN NaN NaN 29016.5 11556.964675 9000.0 19008.25 29016.5 39024.75 49033.0
Age 40034.0 NaN NaN NaN 31.992531 10.043227 15.0 23.0 32.0 41.0 49.0
Gender 40034 2 Male 23959 NaN NaN NaN NaN NaN NaN NaN
Location 40034 4 USA 16000 NaN NaN NaN NaN NaN NaN NaN
GameGenre 40034 5 Sports 8048 NaN NaN NaN NaN NaN NaN NaN
In [12]:
# This is to show target class distibution (Engagement Level) 
plt.figure()
data['EngagementLevel'].value_counts().plot(kind='bar')
plt.title("EngagementLevel distribution")
plt.xlabel("Class")
plt.ylabel("Count")
plt.show()
No description has been provided for this image
In [13]:
# Decision Tree
In [14]:
data_dt = raw.copy()
data_dt = data_dt.drop(columns=['PlayerID'])
# Removing ID column, isn't relevant for behaviours 
In [15]:
# Added to troubleshoot, first result [nan] 
print(data[data['EngagementLevel'].isnull()]['EngagementLevel'].unique())
[]
In [16]:
# Added to troubleshoot, first result nan returning nan for some reason, should show ['Medium' 'High' 'Low']
print(data['EngagementLevel'].unique())
['Medium' 'High' 'Low']
In [17]:
print(data['EngagementLevel'].head(10)) # Check 10 rows of engagement level
0    Medium
1    Medium
2      High
3    Medium
4    Medium
5       Low
6       Low
7    Medium
8    Medium
9      High
Name: EngagementLevel, dtype: object
In [18]:
# Change Gender column c to number
c = {'Male' : 0, 'Female': 1}
data_dt['Gender'] = data_dt['Gender'].map(c)

# Change Location column d to number 
d = {'Asia' : 0, 'Europe': 1, 'Other': 2, 'USA':3}
data_dt['Location'] = data_dt['Location'].map(d)

# change Game Genre column e to number
e = {'Action' : 0, 'RPG': 1, 'Simulation': 2, 'Sports': 3, 'Strategy': 4}
data_dt['GameGenre'] = data_dt['GameGenre'].map(e)

# Chance Game Difficulty column h to number
h = {'Easy' : 0, 'Medium': 1, 'Hard': 2}
data_dt['GameDifficulty'] = data_dt['GameDifficulty'].map(h)

# change EngagementLevel column m (target_map) to number
target_map = {'Low' : 0, 'Medium': 1, 'High': 2}
data_dt['EngagementLevel'] = data_dt['EngagementLevel'].map(target_map)
In [19]:
print(data_dt['EngagementLevel'].head(10)) # check again should now show 0/1/2
0    1
1    1
2    2
3    1
4    1
5    0
6    0
7    1
8    1
9    2
Name: EngagementLevel, dtype: int64
In [20]:
# Features define
features=['Age','Gender','Location','GameGenre','PlayTimeHours','InGamePurchases','GameDifficulty','SessionsPerWeek','AvgSessionDurationMinutes','PlayerLevel','AchievementsUnlocked']
In [21]:
# X, Y, data features and target
X_dt = data_dt[features]
Y_dt = data_dt['EngagementLevel']
In [22]:
# Train and Test Split
X_train_dt, X_test_dt, Y_train_dt, Y_test_dt = train_test_split(X_dt, Y_dt, test_size=0.30, random_state=42, stratify=Y_dt)
In [23]:
dtree = DecisionTreeClassifier(random_state=42)
dtree.fit(X_train_dt, Y_train_dt)
Out[23]:
DecisionTreeClassifier(random_state=42)
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.
DecisionTreeClassifier(random_state=42)
In [24]:
# Evaluate decision tree baseline
Y_pred_dt = dtree.predict(X_test_dt)

print('\n=== Decision Tree: Test Classification Report ===')
print('Accuracy:', accuracy_score(Y_test_dt, Y_pred_dt))
print("Precision (weighted):", precision_score(Y_test_dt, Y_pred_dt, average='weighted'))
print("Recall (weighted):", recall_score(Y_test_dt, Y_pred_dt, average='weighted'))
print("F1 (weighted):", f1_score(Y_test_dt, Y_pred_dt, average='weighted'))
print("\nClassification report:\n", classification_report(Y_test_dt, Y_pred_dt))
=== Decision Tree: Test Classification Report ===
Accuracy: 0.8233286154358505
Precision (weighted): 0.823791267904152
Recall (weighted): 0.8233286154358505
F1 (weighted): 0.8235221247481076

Classification report:
               precision    recall  f1-score   support

           0       0.79      0.80      0.79      3097
           1       0.86      0.85      0.85      5813
           2       0.80      0.81      0.80      3101

    accuracy                           0.82     12011
   macro avg       0.81      0.82      0.81     12011
weighted avg       0.82      0.82      0.82     12011

In [25]:
cm_dt = confusion_matrix(Y_test_dt, Y_pred_dt)
plt.figure(figsize=(7, 5))
sns.heatmap(cm_dt, annot=True, fmt='d', xticklabels=['Low','Medium','High'], yticklabels=['Low','Medium','High'])
plt.title("Decision Tree Confusion Matrix: Test")
plt.xlabel("Predicted")
plt.ylabel("Actual")
plt.show()
No description has been provided for this image
In [26]:
#
# AUC-ROC multiclass OvR
#
In [27]:
y_test_bin = label_binarize(Y_test_dt, classes=[0,1,2])
y_proba_dt = dtree.predict_proba(X_test_dt)

auc_dt = roc_auc_score(y_test_bin, y_proba_dt, multi_class='ovr', average='macro')
print('ROC-AUC (OvR, macro):', auc_dt)
ROC-AUC (OvR, macro): 0.8619144671466762
In [28]:
#
# Decision tree earlier versions: readable
#
In [29]:
dtree_small = DecisionTreeClassifier(max_depth=3, random_state=42)
dtree_small.fit(X_train_dt, Y_train_dt)
Out[29]:
DecisionTreeClassifier(max_depth=3, random_state=42)
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.
DecisionTreeClassifier(max_depth=3, random_state=42)
In [30]:
plt.figure(figsize=(30, 12))
tree.plot_tree(dtree_small, feature_names=features, filled=True, rounded=True, fontsize=14)
plt.title("Decision Tree (max_depth=4)")
plt.show()
No description has been provided for this image
In [31]:
# shows feature importance, drives splits
fi = pd.DataFrame({'feature': features, 'importance': dtree_small.feature_importances_}).sort_values('importance', ascending=False)
print("\nFeature importance (from max_depth=3 tree):")
display(fi)
Feature importance (from max_depth=3 tree):
feature importance
7 SessionsPerWeek 0.678701
8 AvgSessionDurationMinutes 0.320888
10 AchievementsUnlocked 0.000411
0 Age 0.000000
1 Gender 0.000000
2 Location 0.000000
3 GameGenre 0.000000
4 PlayTimeHours 0.000000
5 InGamePurchases 0.000000
6 GameDifficulty 0.000000
9 PlayerLevel 0.000000
In [32]:
#
# Larger tree, controlled leaf breadth
#
In [129]:
sample_dt = pd.DataFrame([[20, 1, 2, 2, 7, 0, 0, 15, 65, 30, 35]], columns=features)
print("DT sample prediction:", dtree_small.predict(sample_dt)[0])  # 0/1/2
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
File ~\anaconda3\Lib\site-packages\pandas\core\internals\construction.py:939, in _finalize_columns_and_data(content, columns, dtype)
    938 try:
--> 939     columns = _validate_or_indexify_columns(contents, columns)
    940 except AssertionError as err:
    941     # GH#26429 do not raise user-facing AssertionError

File ~\anaconda3\Lib\site-packages\pandas\core\internals\construction.py:986, in _validate_or_indexify_columns(content, columns)
    984 if not is_mi_list and len(columns) != len(content):  # pragma: no cover
    985     # caller's responsibility to check for this...
--> 986     raise AssertionError(
    987         f"{len(columns)} columns passed, passed data had "
    988         f"{len(content)} columns"
    989     )
    990 if is_mi_list:
    991     # check if nested list column, length of each sub-list should be equal

AssertionError: 4 columns passed, passed data had 11 columns

The above exception was the direct cause of the following exception:

ValueError                                Traceback (most recent call last)
Cell In[129], line 1
----> 1 sample_dt = pd.DataFrame([[20, 1, 2, 2, 7, 0, 0, 15, 65, 30, 35]], columns=features)
      2 print("DT sample prediction:", dtree_small.predict(sample_dt)[0])

File ~\anaconda3\Lib\site-packages\pandas\core\frame.py:851, in DataFrame.__init__(self, data, index, columns, dtype, copy)
    849     if columns is not None:
    850         columns = ensure_index(columns)
--> 851     arrays, columns, index = nested_data_to_arrays(
    852         # error: Argument 3 to "nested_data_to_arrays" has incompatible
    853         # type "Optional[Collection[Any]]"; expected "Optional[Index]"
    854         data,
    855         columns,
    856         index,  # type: ignore[arg-type]
    857         dtype,
    858     )
    859     mgr = arrays_to_mgr(
    860         arrays,
    861         columns,
   (...)
    864         typ=manager,
    865     )
    866 else:

File ~\anaconda3\Lib\site-packages\pandas\core\internals\construction.py:520, in nested_data_to_arrays(data, columns, index, dtype)
    517 if is_named_tuple(data[0]) and columns is None:
    518     columns = ensure_index(data[0]._fields)
--> 520 arrays, columns = to_arrays(data, columns, dtype=dtype)
    521 columns = ensure_index(columns)
    523 if index is None:

File ~\anaconda3\Lib\site-packages\pandas\core\internals\construction.py:845, in to_arrays(data, columns, dtype)
    842     data = [tuple(x) for x in data]
    843     arr = _list_to_arrays(data)
--> 845 content, columns = _finalize_columns_and_data(arr, columns, dtype)
    846 return content, columns

File ~\anaconda3\Lib\site-packages\pandas\core\internals\construction.py:942, in _finalize_columns_and_data(content, columns, dtype)
    939     columns = _validate_or_indexify_columns(contents, columns)
    940 except AssertionError as err:
    941     # GH#26429 do not raise user-facing AssertionError
--> 942     raise ValueError(err) from err
    944 if len(contents) and contents[0].dtype == np.object_:
    945     contents = convert_object_array(contents, dtype=dtype)

ValueError: 4 columns passed, passed data had 11 columns
In [34]:
#X=data[features]
In [35]:
#Y=data['EngagementLevel']
In [36]:
# Baseline Decision Tree
dtree=DecisionTreeClassifier(random_state=42)
dtree.fit(X_train_dt, Y_train_dt)
Out[36]:
DecisionTreeClassifier(random_state=42)
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.
DecisionTreeClassifier(random_state=42)
In [37]:
print(dtree.classes_) # Classes = engagement level, 0=low, 1=medium, 2=high
[0 1 2]
In [38]:
Y_pred_dt_test = dtree.predict(X_test_dt)
print(classification_report(Y_test_dt, Y_pred_dt_test))

cm = confusion_matrix(Y_test_dt, Y_pred_dt_test)
plt.figure(figsize=(8,5))
sns.heatmap(cm, annot=True, fmt='d', 
            xticklabels=['Low','Medium','High'],
            yticklabels=['Low','Medium','High'])
plt.ylabel('Actual')
plt.xlabel('Predicted')
plt.title('Decision Tree Confusion Matrix: test')
plt.show()
              precision    recall  f1-score   support

           0       0.79      0.80      0.79      3097
           1       0.86      0.85      0.85      5813
           2       0.80      0.81      0.80      3101

    accuracy                           0.82     12011
   macro avg       0.81      0.82      0.81     12011
weighted avg       0.82      0.82      0.82     12011

No description has been provided for this image
In [39]:
# Start: initial trial and error with Decision Tree
# This is illegible, diagram too big # may no longer work following changes above but kept in for discussion point
#plt.figure(figsize=(30, 10))
#tree.plot_tree(dtree,feature_names=features, filled=True, rounded=True)
#plt.show()
In [40]:
# shows the top decisions instead to show more important split, max depth 3 chosen to better view the text
#dtree_small = DecisionTreeClassifier(max_depth=3, random_state=42)

#dtree_small.fit(X_train_dt, Y_train_dt)

# Added font size to read the visual better 
#plt.figure(figsize=(30, 12))
#tree.plot_tree(dtree_small, feature_names=features, filled=True, rounded=True, fontsize=14)
#plt.show()
# plt.savefig("full_decision_tree.png", dpi=300) # saves image of diagram
In [41]:
# Check feature importance: this is to confirm if sessions per week dominates as per the dtree
pd.DataFrame({'feature': features, 'importance': dtree_small.feature_importances_}).sort_values('importance', ascending=False)
Out[41]:
feature importance
7 SessionsPerWeek 0.678701
8 AvgSessionDurationMinutes 0.320888
10 AchievementsUnlocked 0.000411
0 Age 0.000000
1 Gender 0.000000
2 Location 0.000000
3 GameGenre 0.000000
4 PlayTimeHours 0.000000
5 InGamePurchases 0.000000
6 GameDifficulty 0.000000
9 PlayerLevel 0.000000
In [42]:
# Max depth changed from 3 to 6 to view furhter down, with and added max number of leaf nodes to focus breadth display and keep text legible
dtree_new = DecisionTreeClassifier(max_depth=6, max_leaf_nodes=16, random_state=42)
dtree_new =dtree_new .fit(X_train_dt, Y_train_dt)

# figure size and font size also adjusted for display purposes
plt.figure(figsize=(40, 14))
tree.plot_tree(dtree_new , feature_names=features, filled=True, rounded=True, fontsize=12)
plt.show()
No description has been provided for this image
In [43]:
# Observations 
# Root Node: Sessions per week (most dominant), total of 40,304 samples, class distribution: Low = 10234, Medium = 19,374, High = 10,336
# Left: very low session frequency <=2.5, # Right: more active players > 2.5
# Left (low) has 5,965 samples,gini 0.272 # Mostly low engagement: 5,034/5,965. 
# Sessions per week, session duration more likely to correlate higher engagement 
In [44]:
# print(dtree.predict([[31, 0, 3, 1, 12, 0.2, 0, 9, 94, 49, 24]])) 
# ['Age','Gender','Location','GameGenre','PlayTimeHours','InGamePurchases','GameDifficulty','SessionsPerWeek','AvgSessionDurationMinutes','PlayerLevel','AchievementsUnlocked']
In [45]:
#
# Sample prediction
#
In [ ]:
label_map = {0: 'Low', 1: 'Medium', 2: 'High'} # change 0, 1, 2 back to low, medium, high for clearer sample result display
#pred_num = dtree_new.predict(sample)[0]

sample = pd.DataFrame([[20, 1, 2, 2, 7, 0, 0, 15, 65, 30, 35]], columns=features) # create sample set to predict outcome, goal = 2 (high)
pred_num = dtree_new.predict(sample)[0]
print(dtree_new.predict(sample))

#pred = dtree_new.predict(sample)[0] # print as Low instead of [0], Medium instead of [1] or High instead of [2]
print("predicted class:", pred_num, "=", label_map[pred_num])
In [47]:
# Sample checks, examples and column headers: 
# ([[20, 1, 2, 2, 7, 0, 0, 15, 65, 30, 35]] = Medium: Halving avg session duration mins and no other changes is enough to change from High to Medium
# [20, 1, 2, 2, 7, 0, 0, 15, 130, 30, 35] = High: adjusted some values from row 2 example to see if still high
# [22, 1, 3, 3, 8, 0, 0, 16, 142, 35, 41] = High: example user row 2
# [40, 0, 3, 4, 12.5, 1, 2, 5, 120, 50, 10] = Medium: randomly selected
# [31, 0, 3, 1, 12, 0.2, 0, 9, 94, 49, 24] = Medium: mean values used
#'Age','Gender','Location','GameGenre','PlayTimeHours','InGamePurchases','GameDifficulty','SessionsPerWeek','AvgSessionDurationMinutes','PlayerLevel','AchievementsUnlocked'
In [48]:
#
# Hypertuning gridsearch
#
In [49]:
param_grid_dt = {'max_depth': [3,5,7, None],'min_samples_split': [2, 10, 20],'min_samples_leaf': [1,5,10]}

grid_dt = GridSearchCV(DecisionTreeClassifier(random_state=42), param_grid=param_grid_dt, scoring='f1_macro', cv=5, n_jobs=1)

grid_dt.fit(X_train_dt, Y_train_dt)

print("\nBest DT params:", grid_dt.best_params_)
print("Best CV F1_macro:", grid_dt.best_score_)

best_dt = grid_dt.best_estimator_
Y_pred_best_dt = best_dt.predict(X_test_dt)

print("\n=== Decision Tree: Tuned (Test) ===")
print("Accuracy:", accuracy_score(Y_test_dt, Y_pred_best_dt))
print("Precision (weighted):", precision_score(Y_test_dt, Y_pred_best_dt, average='weighted'))
print("Recall (weighted):", recall_score(Y_test_dt, Y_pred_best_dt, average='weighted'))
print("F1 (weighted):", f1_score(Y_test_dt, Y_pred_best_dt, average='weighted'))

cm_best = confusion_matrix(Y_test_dt, Y_pred_best_dt)
plt.figure(figsize=(7, 5))
sns.heatmap(cm_best, annot=True, fmt='d', xticklabels=['Low','Medium','High'], yticklabels=['Low','Medium','High'])
plt.title("Decision Tree Confusion Matrix: Tuned (Test)")
plt.xlabel("Predicted")
plt.ylabel("Actual")
plt.show()

# Tuned DT AUC-ROC
Y_proba_best_dt = best_dt.predict_proba(X_test_dt)
auc_best_dt = roc_auc_score(y_test_bin, Y_proba_best_dt, multi_class='ovr', average='macro')
print("ROC-AUC (OvR, macro) tuned:", auc_best_dt)
Best DT params: {'max_depth': None, 'min_samples_leaf': 10, 'min_samples_split': 2}
Best CV F1_macro: 0.8979267752849018

=== Decision Tree: Tuned (Test) ===
Accuracy: 0.9035051203063859
Precision (weighted): 0.9036410456610104
Recall (weighted): 0.9035051203063859
F1 (weighted): 0.9032802488440882
No description has been provided for this image
ROC-AUC (OvR, macro) tuned: 0.9343364556006079
In [50]:
#
# End of Decisiom Tree Moving onto SVM: Multiclass 
#
In [51]:
# Explore the data, EDA: Exploratory Data Analysis, new copy of raw data to avoid issues if re-running blocks later
eda_df = raw.copy()
eda_df = eda_df.drop(columns=['PlayerID'])
In [52]:
eda_df.head()
Out[52]:
Age Gender Location GameGenre PlayTimeHours InGamePurchases GameDifficulty SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked EngagementLevel
0 43 Male Other Strategy 16.271119 0 Medium 6 108 79 25 Medium
1 29 Female USA Strategy 5.525961 0 Medium 5 144 11 10 Medium
2 22 Female USA Sports 8.223755 0 Easy 16 142 35 41 High
3 35 Male USA Action 5.265351 1 Easy 9 85 57 47 Medium
4 33 Male Europe Action 15.531945 0 Medium 2 131 95 37 Medium
In [53]:
# To show info of the data: hopefully non-null count matches entries again. Dtype: Int, Float, Object (text)
eda_df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 40034 entries, 0 to 40033
Data columns (total 12 columns):
 #   Column                     Non-Null Count  Dtype  
---  ------                     --------------  -----  
 0   Age                        40034 non-null  int64  
 1   Gender                     40034 non-null  object 
 2   Location                   40034 non-null  object 
 3   GameGenre                  40034 non-null  object 
 4   PlayTimeHours              40034 non-null  float64
 5   InGamePurchases            40034 non-null  int64  
 6   GameDifficulty             40034 non-null  object 
 7   SessionsPerWeek            40034 non-null  int64  
 8   AvgSessionDurationMinutes  40034 non-null  int64  
 9   PlayerLevel                40034 non-null  int64  
 10  AchievementsUnlocked       40034 non-null  int64  
 11  EngagementLevel            40034 non-null  object 
dtypes: float64(1), int64(6), object(5)
memory usage: 3.7+ MB
In [54]:
# Check unique values per cell
eda_df.nunique()
# no column shows only 1 unique value, ID can be ignored
Out[54]:
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
In [55]:
# Create numerical columns
num_cols=['Age','PlayTimeHours','InGamePurchases','SessionsPerWeek','AvgSessionDurationMinutes',
          'PlayerLevel','AchievementsUnlocked']

# Create categorical variables
cat_cols=['Gender','Location','GameGenre','GameDifficulty','EngagementLevel']
In [56]:
# Checking summary statistics
eda_df[num_cols].describe().T
Out[56]:
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
InGamePurchases 40034.0 0.200854 0.400644 0.000000 0.000000 0.000000 0.000000 1.000000
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
In [57]:
# Observations
# Average Age = 31.99, range is 15-49, midling age range diversity
# Play Time average of 12 hours, randing 0.0 - 23, 75% had up to 17.96
# In game purchases binary (max of 1), mean of 0.2, low variance with over 75% making no purchases
# Sessions per week - 9.47 average, 75% 14 sessions per week, 50% 9 per week max of 19, 
# 50% play up to 95 minutes per session, 75% play up to 137 minutes as an average session duration mins
# Playerlevel range of 1-99, average of 49.65
# Achievement Unlocked max of 49, min 0, mean of 24 
In [58]:
#
# Histograms
#
In [59]:
# Creating histograms of number columns
eda_df[num_cols].hist(figsize=(12,10))
plt.show()
No description has been provided for this image
In [60]:
# Histograms shows most values sit where expected, no outliers 
# No Skew, they're semmetrical with no tails or spikes - megative kurtosis, uniform distribution (exept in game purchase: binary yes =1, no= =0)
# difficult to split players into behaviour types with all engagement levels
# Suitable data set for model training, Decision Tree, SVM, Clustering. 
# look at feature importance, what drives engagement (Decision Tree & importance shows session duration) 
In [61]:
# Explore histograms further, low, medium, high
# import matplotlib.pyplot as plt # moved to top 

# Loop through each engagement level
for level in ['Low', 'Medium', 'High']:
    eda_df[eda_df['EngagementLevel'] == level][num_cols].hist(figsize=(12, 10))
    plt.suptitle(f'Histograms for {level} Engagement', fontsize=16)
    plt.show()
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
In [62]:
# Print the % sub categories of each category
for i in cat_cols:
    print(eda_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
****************************************
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
****************************************
In [63]:
# Engagement level almost half: medium, quarter: High, quarter: Low
# Male Female is approx 60/40 split
# Location: 39% as USA, followed by Europe 29% and Asia 20% (other 0.09%)
# Game Genre almost even split, 19-20% per genre
# Game Difficulty Almost 50% at Easy, 30% playing Medium, 20% playing hard: difficulty gradual decrease
In [64]:
#
# Bar Charts
#
In [65]:
# To explor Categorical Columns, bar chart will be used to visually group engagement level by catergories
for i in cat_cols:
    if i!='EngagementLevel':
        (pd.crosstab(eda_df[i],eda_df['EngagementLevel'],normalize='index')*100).plot(kind='bar',figsize=(8,4),stacked=True)
        plt.ylabel('Percentage EngagementLevel %')
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
In [66]:
# Repeat of bar chart with added % display. Visually they looked the same size so the % will add a little extra context
for i in cat_cols:
    if i!='EngagementLevel':
        #(pd.crosstab(eda_df[i],eda_df['EngagementLevel'],normalize='index')*100).plot(kind='bar',figsize=(8,4),stacked=True)
        ax = (pd.crosstab(eda_df[i],eda_df['EngagementLevel'],normalize='index')*100).plot(kind='bar',figsize=(8,4),stacked=True)
        
        plt.ylabel('Percentage EngagementLevel %') # Add the % label
        plt.title(f'Engagement Level by {i}') # To add a title as it felt lacking on previous attempt

        for container in ax.containers: # This additional line adds % bar label and formats font and decimal 
           ax.bar_label(container, fmt='%.1f%%', label_type='center', fontsize=8) # label type 'edge' wasn't showing on all, changed to 'center'

        plt.show()
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
In [67]:
# Mean of numerical variables grouped by Engagement Level
eda_df.groupby(['EngagementLevel'])[num_cols].mean()
# Observation: 
# High engagement mean for session duration min is almost double that of low enagement
# Player level and achievements are lower for low engagement, High and Medium are similar
Out[67]:
Age PlayTimeHours InGamePurchases SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked
EngagementLevel
High 31.920085 12.069238 0.206463 14.254547 131.921827 50.823723 25.095975
Low 31.896939 12.104915 0.197307 4.530511 66.882119 46.101414 22.661565
Medium 32.082120 11.957503 0.199752 9.553267 89.856405 50.926293 25.216424
In [68]:
# Plot the correlation between numerical variables to explort relationship between numerical variables
plt.figure(figsize=(15,8))
sns.heatmap(eda_df[num_cols].corr(), annot=True, fmt='0.2f', cmap='YlOrRd') # not much contrast... later realised this is missing engagement level
Out[68]:
<Axes: >
No description has been provided for this image
In [69]:
# Plot the correlation between numerical variable: using cool colours instead this time and adjusting vmin, vmax
# This time with Engagement Level (as numbers) included
eda_df['EngagementLevel_num'] = eda_df['EngagementLevel'].map({'Low': 0, 'Medium': 1, 'High': 2})

plt.figure(figsize=(15,8))
sns.heatmap(eda_df[num_cols + ['EngagementLevel_num']].corr(), annot=True, fmt='0.2f', cmap='coolwarm',vmin=-0.1, vmax=0.1) 
# Below is the same with warm colours: very high contrast, either pale yellow or deep red, # out line above to view 
# sns.heatmap(eda_df[num_cols + ['EngagementLevel_num']].corr(), annot=True, fmt='0.2f', cmap='YlOrRd', vmin=0.1, vmax=0.1) 
Out[69]:
<Axes: >
No description has been provided for this image
In [70]:
# Observation: Sessions per week, average duration minutes, player level and engagement unlocked have highest engagement correlation
# Play time hours, age, inGame purchases have less correlation 
In [71]:
# Plot the correlation between numerical variable LOW ENGAGEMENT FILTER
plt.figure(figsize=(15,8))
sns.heatmap(eda_df[eda_df['EngagementLevel']=='Low'][num_cols].corr(), annot=True, fmt='0.2f', cmap='coolwarm', vmin=-0.1, vmax=0.1)
plt.title('Low Engagement Filter')
Out[71]:
Text(0.5, 1.0, 'Low Engagement Filter')
No description has been provided for this image
In [72]:
# Plot the correlation between numerical variable # HIGH ENGAGEMENT FILTER
plt.figure(figsize=(15,8))
sns.heatmap(eda_df[eda_df['EngagementLevel']=='High'][num_cols].corr(), annot=True, fmt='0.2f', cmap='coolwarm', vmin=-0.1, vmax=0.1)
plt.title('High Engagement Filter')
Out[72]:
Text(0.5, 1.0, 'High Engagement Filter')
No description has been provided for this image
In [73]:
# Plot the correlation between numerical variable MEDIUM ENGAGEMENT FILTER
plt.figure(figsize=(15,8))
# sns.heatmap(eda_df[num_cols].corr(), annot=True, fmt='0.2f', cmap='YlOrRd') # not much contrast
sns.heatmap(eda_df[eda_df['EngagementLevel']=='Medium'][num_cols].corr(), annot=True, fmt='0.2f', cmap='coolwarm', vmin=-0.1, vmax=0.1)
plt.title('Medium Engagement Filter')
Out[73]:
Text(0.5, 1.0, 'Medium Engagement Filter')
No description has been provided for this image
In [74]:
#
# Support Vector Machine Learning Supervised Learning
# 
In [75]:
# Goal: predict engagement level low-high 
In [76]:
# starting over with copy of raw just in case of changes above
svm_df = raw.copy()
# drop playerID again after starting over with a copy of raw as it's an identifier and no use to model learning
svm_df = svm_df.drop(columns=['PlayerID'])
svm_df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 40034 entries, 0 to 40033
Data columns (total 12 columns):
 #   Column                     Non-Null Count  Dtype  
---  ------                     --------------  -----  
 0   Age                        40034 non-null  int64  
 1   Gender                     40034 non-null  object 
 2   Location                   40034 non-null  object 
 3   GameGenre                  40034 non-null  object 
 4   PlayTimeHours              40034 non-null  float64
 5   InGamePurchases            40034 non-null  int64  
 6   GameDifficulty             40034 non-null  object 
 7   SessionsPerWeek            40034 non-null  int64  
 8   AvgSessionDurationMinutes  40034 non-null  int64  
 9   PlayerLevel                40034 non-null  int64  
 10  AchievementsUnlocked       40034 non-null  int64  
 11  EngagementLevel            40034 non-null  object 
dtypes: float64(1), int64(6), object(5)
memory usage: 3.7+ MB
In [77]:
# Map target Engagement Level to numberic class as required by scikit learn classifiers
target_map = {'Low': 0, 'Medium': 1, 'High': 2}
svm_df["EngagementLevel"] = svm_df['EngagementLevel'].map(target_map)

# categorical predictors so SVM can use them, drop first true to avoid dummy variable trap
cat_cols = ['Gender', 'Location', 'GameGenre', 'GameDifficulty']
svm_df = pd.get_dummies(svm_df, columns=cat_cols, drop_first=True)

# Split into X and Y for SVM
X_svm = svm_df.drop(columns=['EngagementLevel'])
Y_svm = svm_df['EngagementLevel']
In [78]:
# Train and Test split using statify so low to high proportians are preserved, e,g, medium is = to high and low combined
X_train, X_test, Y_train, Y_test = train_test_split(X_svm, Y_svm, test_size=0.3, random_state=42, stratify=Y_svm)                                     
In [79]:
#
# SVM feature Scaling
#
In [80]:
# SVM requires scaling as it's sensitive to feature scale # Check if changing engagement level to numeric causes weighting issue
# standard scaler: mean=1, std=1, no single feature dominating because of its units
sc = StandardScaler()
X_train_scaled = sc.fit_transform(X_train)
X_test_scaled = sc.transform(X_test)
In [81]:
# Function to evaluate in re-usable way, won't print until called, shows accuracy, precision, F1, ROC-AUC


# Print evaluation metrics
def evaluate_multiclass(model_name, Y_true, Y_pred, Y_scores=None):
    print(f"\n=== {model_name} ===")
    print('Accuracy:', accuracy_score(Y_true, Y_pred))
    print('Macro Precision:', precision_score(Y_true, Y_pred, average='macro'))
    print('Macro Recall:', recall_score(Y_true, Y_pred, average='macro'))
    print('Macros F1:', f1_score(Y_true, Y_pred, average='macro'))
    print("\nClassification report:\n", classification_report(Y_true, Y_pred))

    cm = confusion_matrix(Y_true, Y_pred)
    plt.figure(figsize=(7,5,))
    sns.heatmap(cm, annot=True, fmt="d", xticklabels=['Low', 'Medium', 'High'], yticklabels=['Low', 'Medium', 'High'])
    plt.xlabel('Predicted')
    plt.ylabel('Actual')
    plt.title(f"Confusion Matrix: {model_name}")
    plt.show()

    if Y_scores is not None:
        Y_true_bin = label_binarize(Y_true, classes=[0,1,2])
        auc = roc_auc_score(Y_true_bin, Y_scores, multi_class='ovr', average='macro')
        print('ROC-AUC (OvR, macro):', auc)
In [82]:
#
# SVM Baseline models lineas and RBF
#
In [83]:
#
# SVM: Linear Kernel
#
In [84]:
# Linear kernel: tries to seperate using straight hyperplanes 
# useful baselin but potential underfit if there's non-linear relationships
svm_linear = SVC(kernel='linear', random_state=42) 
svm_linear.fit(X_train_scaled, Y_train)

Y_pred_linear = svm_linear.predict(X_test_scaled)

Y_scores_linear = svm_linear.decision_function(X_test_scaled) # for ROC-AUC OvR

evaluate_multiclass("SVM Linear: Baseline", Y_test, Y_pred_linear, Y_scores_linear) 
# evaluate multiclass falls above funtion (accuracy, recall, precision, F1, ROD-AUC etc)
=== SVM Linear: Baseline ===
Accuracy: 0.8242444425942885
Macro Precision: 0.8292613714313836
Macro Recall: 0.8098208044252061
Macros F1: 0.8182829832460944

Classification report:
               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.84      0.86      3101

    accuracy                           0.82     12011
   macro avg       0.83      0.81      0.82     12011
weighted avg       0.82      0.82      0.82     12011

No description has been provided for this image
ROC-AUC (OvR, macro): 0.8973885503431439
In [85]:
#
# SVM: RBF Kernel
#
In [86]:
# RBF Kernel: non linear decision boundaries: can perform better when class seperation is non linear
svm_rbf = SVC(kernel='rbf', gamma='scale', C=1, random_state=42)
svm_rbf.fit(X_train_scaled, Y_train)

Y_pred_rbf = svm_rbf.predict(X_test_scaled)
Y_scores_rbf = svm_rbf.decision_function(X_test_scaled)

evaluate_multiclass("SVM RBF: Baseline", Y_test, Y_pred_rbf, Y_scores_rbf)
=== SVM RBF: Baseline ===
Accuracy: 0.8840229789359754
Macro Precision: 0.8905883047982502
Macro Recall: 0.8694950982251637
Macros F1: 0.8788244031066704

Classification report:
               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.88     12011
   macro avg       0.89      0.87      0.88     12011
weighted avg       0.89      0.88      0.88     12011

No description has been provided for this image
ROC-AUC (OvR, macro): 0.9358046958903449
In [87]:
#
# SVM Hyperparameter Tuning (GridSearchCV)
#
In [88]:
# Included to improve generalisation by looking for better hyperpearamaters, cross-validation
# C controls regularisation strength
# small c  smoother margin, more bias less variance. Large C firs training data more tight
# gamma controls how far the influence reaches from a single training point (risk of overfit with large gamma, more complex)
In [89]:
from sklearn.model_selection import GridSearchCV, StratifiedKFold

# Parameter grid 
param_grid = {'C': [0.1, 1, 10],'gamma': ['scale', 0.1, 0.01]}
In [90]:
# uncomment below # Was finding this part slow to run so hid temporarily
In [91]:
# Stratified CV preserves class balance in each fold for low, medium, high
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
In [92]:
# Grid search, scoring f1 micro treats each class equally, especially important for multiclass fairness
# Was getting red text around here, n_jobs=1 should help 
grid_svm = GridSearchCV(estimator=SVC(kernel='rbf', random_state=42),param_grid=param_grid,scoring='f1_macro', cv=cv, n_jobs=1)
In [93]:
# Fit grid search on training data, to avoid test set leakage
grid_svm.fit(X_train_scaled, Y_train)
Out[93]:
GridSearchCV(cv=StratifiedKFold(n_splits=5, random_state=42, shuffle=True),
             estimator=SVC(random_state=42), n_jobs=1,
             param_grid={'C': [0.1, 1, 10], 'gamma': ['scale', 0.1, 0.01]},
             scoring='f1_macro')
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=StratifiedKFold(n_splits=5, random_state=42, shuffle=True),
             estimator=SVC(random_state=42), n_jobs=1,
             param_grid={'C': [0.1, 1, 10], 'gamma': ['scale', 0.1, 0.01]},
             scoring='f1_macro')
SVC(C=10, gamma=0.01, random_state=42)
SVC(C=10, gamma=0.01, random_state=42)
In [94]:
print("Best SVM parameters:", grid_svm.best_params_)
print("Best cross-validated F1 (macro):", grid_svm.best_score_)
Best SVM parameters: {'C': 10, 'gamma': 0.01}
Best cross-validated F1 (macro): 0.883176167534422
In [95]:
# Evaluate tuned model on test set
best_svm = grid_svm.best_estimator_
In [130]:
Y_pred_best = best_svm.predict(X_test_scaled)
Y_scores_best = best_svm.decision_function(X_test_scaled)
In [131]:
evaluate_multiclass("SVM RBF: Tuned", Y_test, Y_pred_best, Y_scores_best)
=== SVM RBF: Tuned ===
Accuracy: 0.8908500541170593
Macro Precision: 0.897789562042932
Macro Recall: 0.8761560863527649
Macros F1: 0.8857104115776595

Classification report:
               precision    recall  f1-score   support

           0       0.89      0.83      0.86      3097
           1       0.87      0.94      0.91      5813
           2       0.93      0.86      0.89      3101

    accuracy                           0.89     12011
   macro avg       0.90      0.88      0.89     12011
weighted avg       0.89      0.89      0.89     12011

No description has been provided for this image
ROC-AUC (OvR, macro): 0.9363400491226409
In [98]:
#uncomment above
In [99]:
#
# Clustering: K-means
#
In [100]:
# move to top once tested
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
import warnings
warnings.filterwarnings("ignore")
In [101]:
# As usual start with clean copy of raw unedited
kmean_df = raw.copy()
In [102]:
# Usual data check
kmean_df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 40034 entries, 0 to 40033
Data columns (total 13 columns):
 #   Column                     Non-Null Count  Dtype  
---  ------                     --------------  -----  
 0   PlayerID                   40034 non-null  int64  
 1   Age                        40034 non-null  int64  
 2   Gender                     40034 non-null  object 
 3   Location                   40034 non-null  object 
 4   GameGenre                  40034 non-null  object 
 5   PlayTimeHours              40034 non-null  float64
 6   InGamePurchases            40034 non-null  int64  
 7   GameDifficulty             40034 non-null  object 
 8   SessionsPerWeek            40034 non-null  int64  
 9   AvgSessionDurationMinutes  40034 non-null  int64  
 10  PlayerLevel                40034 non-null  int64  
 11  AchievementsUnlocked       40034 non-null  int64  
 12  EngagementLevel            40034 non-null  object 
dtypes: float64(1), int64(7), object(5)
memory usage: 4.0+ MB
In [103]:
# Usual data check
kmean_df.head()
Out[103]:
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
In [104]:
# Looking at behaviours such as play intensity and progression
# Based on decision tree and exploratory data analysis
features = ['SessionsPerWeek', 'AvgSessionDurationMinutes','PlayerLevel','AchievementsUnlocked']

# Sample of 200 for sccatter plots, first attempt had a grid so full it was a solid blob blue square without sample size  
sample_df = kmean_df.sample(200, random_state=42)
X_cluster = sample_df[features]

# Scale feature using k-means using distance: euclidean
# scaling is a must or features with larger ranges will dominate clustering
scaler = StandardScaler()
X_cluster_scaled = scaler.fit_transform(X_cluster)

# Fit K-means with k=3, later checked with hierarchical clustering
Kmeans = KMeans(n_clusters=3, random_state=42)
labels = Kmeans.fit_predict(X_cluster_scaled)

# Store cluster labels back onto sample for visual
sample_df['label'] = labels
In [105]:
# this was added to use a sample size of 200 instead 40,000
sample_df = kmean_df.sample(200, random_state=42)

# This alone brought back a full grid of dots, no pattern decipherable
plt.scatter(sample_df['SessionsPerWeek'], sample_df['AvgSessionDurationMinutes'])
plt.xlabel('SessionsPerWeek')
plt.ylabel('AvgSessionDurationMinutes')
plt.title('Scatter Plot of 200 random data points')
plt.show()
No description has been provided for this image
In [106]:
Km_sample = KMeans(n_clusters=3)
Km_sample.fit(sample_df[['SessionsPerWeek', 'AvgSessionDurationMinutes']])
Out[106]:
KMeans(n_clusters=3)
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.
KMeans(n_clusters=3)
In [107]:
# uses kmeans for clustering and adds colour visual seperation
labels_sample = Km_sample.labels_
sample_df['label'] = labels_sample
sns.scatterplot(x=sample_df['SessionsPerWeek'],y=sample_df['AvgSessionDurationMinutes'],hue=sample_df['label'],palette='Set1')

# Scatter plots repeated below with slighyly different feature combination
Out[107]:
<Axes: xlabel='SessionsPerWeek', ylabel='AvgSessionDurationMinutes'>
No description has been provided for this image
In [108]:
# this was added to use a sample size of 200 instead 40,000
sample_df = kmean_df.sample(200, random_state=42)

# This alone brought back a full grid of dots, no pattern decipherable
plt.scatter(sample_df['SessionsPerWeek'], sample_df['PlayerLevel'])
plt.xlabel('SessionsPerWeek')
plt.ylabel('PlayerLevel')
plt.title('Scatter Plot of 200 random data points')
plt.show()
No description has been provided for this image
In [109]:
km_sample = KMeans(n_clusters=3)
km_sample.fit(sample_df[['SessionsPerWeek', 'PlayerLevel']])
Out[109]:
KMeans(n_clusters=3)
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.
KMeans(n_clusters=3)
In [110]:
labels_sample = km_sample.labels_
sample_df['label'] = labels_sample
sns.scatterplot(x=sample_df['SessionsPerWeek'],y=sample_df['PlayerLevel'],hue=sample_df['label'],palette='Set1')
Out[110]:
<Axes: xlabel='SessionsPerWeek', ylabel='PlayerLevel'>
No description has been provided for this image
In [111]:
# this was added to use a sample size of 100 instead 40,000
sample_df = kmean_df.sample(200, random_state=42)

# This alone brought back a full grid of dots, no pattern decipherable
plt.scatter(sample_df['SessionsPerWeek'], sample_df['Age'])
plt.xlabel('SessionsPerWeek')
plt.ylabel('Age')
plt.title('Scatter Plot of 200 random data points')
plt.show()
No description has been provided for this image
In [112]:
km_sample = KMeans(n_clusters=3)
km_sample.fit(sample_df[['SessionsPerWeek', 'Age']])
Out[112]:
KMeans(n_clusters=3)
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.
KMeans(n_clusters=3)
In [113]:
labels_sample = km_sample.labels_
sample_df['label'] = labels_sample
sns.scatterplot(x=sample_df['SessionsPerWeek'],y=sample_df['Age'],hue=sample_df['label'],palette='Set1')
Out[113]:
<Axes: xlabel='SessionsPerWeek', ylabel='Age'>
No description has been provided for this image
In [114]:
plt.scatter(sample_df['SessionsPerWeek'], sample_df['AchievementsUnlocked'])
plt.xlabel('SessionsPerWeek')
plt.ylabel('AchievementsUnlocked')
plt.title('Scatter Plot of 200 random data points')
plt.show()
No description has been provided for this image
In [115]:
km_sample = KMeans(n_clusters=3)
km_sample.fit(sample_df[['SessionsPerWeek', 'AchievementsUnlocked']])
Out[115]:
KMeans(n_clusters=3)
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.
KMeans(n_clusters=3)
In [116]:
labels_sample = km_sample.labels_
sample_df['label'] = labels_sample
sns.scatterplot(x=sample_df['SessionsPerWeek'],y=sample_df['AchievementsUnlocked'],hue=sample_df['label'],palette='Set1')
plt.show()
No description has been provided for this image
In [117]:
#labels_sample = km_sample.labels_
#sample_df['label'] = labels_sample
#sns.scatterplot(x=sample_df['PlayerLevel'],y=sample_df['AchievementsUnlocked'],hue=sample_df['label'],palette='Set1')
#plt.show()
In [118]:
#
# Inertia: elbow method
#
In [119]:
# Inertia WCSS measures within cluster variance. Low = bettwe but always decreases as k increases
# elbow point is where adding more clusters gives diminishin returns as visible below

WCSS=[]
K = range(1,9)
for i in K:
    Kmeanmodel=KMeans(n_clusters=i,init='k-means++', max_iter=300, n_init=10, random_state=42)
    Kmeanmodel.fit(sample_df[['SessionsPerWeek','AchievementsUnlocked']])
    WCSS.append(Kmeanmodel.inertia_)
In [120]:
plt.plot(range(1,9),WCSS)
plt.xlabel('Number of Clusters')
plt.ylabel('WCSS')
plt.show()
No description has been provided for this image
In [121]:
#
# Silhouette Score
#
In [122]:
# Silhouette ranges from -1 to +1 
# Higher = better defined clusters. If the values are near 0 it means overlapping clusters
features = ['SessionsPerWeek', 'AvgSessionDurationMinutes','PlayerLevel','AchievementsUnlocked']

X = kmean_df[features]

X_scaled = StandardScaler().fit_transform(X)

kmeans = KMeans(n_clusters=3, random_state=42)
labels = kmeans.fit_predict(X_scaled)

#sil_score = silhouette_score(X_cluster_scaled, labels)

#print('Silhouette score:', sil_score)
print('Silhouette score:', silhouette_score(X_scaled,labels))
Silhouette score: 0.17217543022468818
In [123]:
# Summarise cluster profiles for semgent interpretation
kmean_df['cluster'] = labels

kmean_df.groupby('cluster')[features].mean()
Out[123]:
SessionsPerWeek AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked
cluster
0 9.450387 95.353391 24.622619 14.373077
1 9.332721 94.056052 77.771159 16.656020
2 9.606095 94.937914 47.113881 39.628016
In [124]:
#
# Hierarchical Clustering
#
In [125]:
# Not sure if the k-means painted a clear picture on behavioural clusters and patterns 
# Additional Clustering used, Hierarchical Clustering (ward) Simple short code and looks wider patterns
# Dendogram shows how samples merge into clusters, smaller sample used initially
# the method is ward, which tends to create compact clusters to compare with k-means
from scipy.cluster.hierarchy import linkage, dendrogram

X_sample50 = X_cluster_scaled[:50]


linked = linkage(X_sample50, method='ward')
plt.figure(figsize=(10,10))
dendrogram(linked)
plt.title('Hierarchical Clistering Dendrogram')
plt.xlabel('Samples')
plt.ylabel('Distance')
plt.show()
No description has been provided for this image
In [126]:
from scipy.cluster.hierarchy import linkage, dendrogram

X_sample100 = X_cluster_scaled[:100]


linked = linkage(X_sample100, method='ward')
plt.figure(figsize=(10,10))
dendrogram(linked)
plt.title('Hierarchical Clistering Dendrogram')
plt.xlabel('Samples')
plt.ylabel('Distance')
plt.show()
No description has been provided for this image
In [127]:
# Used to view a larger sample and dimensions adjusted to be used for cover page
from scipy.cluster.hierarchy import linkage, dendrogram

X_sample100 = X_cluster_scaled[:1000]


linked = linkage(X_sample100, method='ward')
plt.figure(figsize=(21,29.7))
dendrogram(linked)
plt.title('Hierarchical Clistering Dendrogram')
plt.xlabel('Samples')
plt.ylabel('Distance')
plt.show()
No description has been provided for this image
In [128]:
# Observation: Hierarchical clustering Dendrogram reveals 3 clusters (if Distance threshold = 5 and sample = 30
# supports choice of k= 3 used in K-means model (although higher sample shows 4 clisters at 8 distance threshold)
# Engagement is driven more by behaviour 

# Oramge: lower left: low sessions, short duration, few achievements, similar group (Low engagement cluster)
# Green: left center: later merging, moderate but vaired. Mixed sessions, mixed progression, not heavy users (Medium engagement cluster)
# Red: Right Center lower: more compact than green, less variation, higher than orange. Longer sessions, high sessions, high achievemnt (High cluster)
# Purple: Right: Hardcore grinding playstle. Very High session or long durations. Specialised high engagement subgroup. Intenced focused play behabiour

# Medium = opportunity. Red & Purple = retention focus
In [ ]: