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()
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()
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()
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
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()
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
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()
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()
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 %')
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()
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: >
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: >
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')
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')
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')
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
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
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
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()
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'>
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()
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'>
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()
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'>
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()
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()
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()
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()
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()
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()
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 [ ]: