In [1]:
# IMPORTS - All dependencies consolidated
# Required libraries for data manipulation, visualisation, and machine learning
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split, GridSearchCV, StratifiedShuffleSplit
from sklearn.preprocessing import LabelEncoder, StandardScaler, label_binarize
from sklearn.metrics import (
accuracy_score, classification_report, roc_auc_score, silhouette_score,
f1_score, confusion_matrix, ConfusionMatrixDisplay, roc_curve, auc,
precision_score, recall_score
)
from sklearn.tree import DecisionTreeClassifier, plot_tree
from sklearn.svm import LinearSVC, SVC
from sklearn.cluster import KMeans
from sklearn.decomposition import PCA
# Set plot style for consistency throughout the analysis
plt.style.use('seaborn-v0_8-whitegrid')
# Set random seed for reproducibility
RANDOM_STATE = 42
In [2]:
# LOAD DATASET
# Source: Kaggle - Predict Online Gaming Behaviour Dataset
df = pd.read_csv('online_gaming_behavior_dataset.csv')
print(f'Dataset Shape: {df.shape[0]} players, {df.shape[1]} features')
print(f'\nColumn Data Types:')
print(df.dtypes)
print(f'\nFirst 5 Records:')
df.head()
Dataset Shape: 40034 players, 13 features Column Data Types: PlayerID int64 Age int64 Gender object Location object GameGenre object PlayTimeHours float64 InGamePurchases int64 GameDifficulty object SessionsPerWeek int64 AvgSessionDurationMinutes int64 PlayerLevel int64 AchievementsUnlocked int64 EngagementLevel object dtype: object First 5 Records:
Out[2]:
| 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 [3]:
# EXPLORATORY DATA ANALYSIS
# Examining data quality, distributions, and summary statistics
# Checks for any missing values
print('DATASET OVERVIEW: \n')
print(f'\nTotal Records: {len(df)}')
print(f'Total Features: {len(df.columns)}')
print('\n MISSING VALUES: \n')
missing = df.isna().sum()
print(f'Total Missing Values: {missing.sum()}')
if missing.sum() > 0:
print(missing[missing > 0])
print('\n NUMERIC FEATURE SUMMARY: \n')
print(df.describe().round(2))
print('\n CATEGORICAL FEATURE VALUES: \n')
for col in df.select_dtypes(include=['object']).columns:
print(f'{col}: {df[col].unique()}')
DATASET OVERVIEW:
Total Records: 40034
Total Features: 13
MISSING VALUES:
Total Missing Values: 0
NUMERIC FEATURE SUMMARY:
PlayerID Age PlayTimeHours InGamePurchases SessionsPerWeek \
count 40034.00 40034.00 40034.00 40034.0 40034.00
mean 29016.50 31.99 12.02 0.2 9.47
std 11556.96 10.04 6.91 0.4 5.76
min 9000.00 15.00 0.00 0.0 0.00
25% 19008.25 23.00 6.07 0.0 4.00
50% 29016.50 32.00 12.01 0.0 9.00
75% 39024.75 41.00 17.96 0.0 14.00
max 49033.00 49.00 24.00 1.0 19.00
AvgSessionDurationMinutes PlayerLevel AchievementsUnlocked
count 40034.00 40034.00 40034.00
mean 94.79 49.66 24.53
std 49.01 28.59 14.43
min 10.00 1.00 0.00
25% 52.00 25.00 12.00
50% 95.00 49.00 25.00
75% 137.00 74.00 37.00
max 179.00 99.00 49.00
CATEGORICAL FEATURE VALUES:
Gender: ['Male' 'Female']
Location: ['Other' 'USA' 'Europe' 'Asia']
GameGenre: ['Strategy' 'Sports' 'Action' 'RPG' 'Simulation']
GameDifficulty: ['Medium' 'Easy' 'Hard']
EngagementLevel: ['Medium' 'High' 'Low']
In [5]:
# EDA VISUALISATIONS
# Exploring relationships between features and engagement levels
fig, axes = plt.subplots(2, 3, figsize=(15, 10))
plt.suptitle('Feature Distributions by Engagement Level', fontsize=14, fontweight='bold')
# Boxplots showing feature distributions across engagement levels
boxplot_configs = [
('Age', axes[0, 0]),
('PlayTimeHours', axes[0, 1]),
('SessionsPerWeek', axes[0, 2]),
('AvgSessionDurationMinutes', axes[1, 0]),
('PlayerLevel', axes[1, 1]),
('AchievementsUnlocked', axes[1, 2])
]
for col, ax in boxplot_configs:
df.boxplot(column=col, by='EngagementLevel', ax=ax)
ax.set_title(col)
ax.set_xlabel('Engagement Level')
ax.set_ylabel(col)
plt.tight_layout()
plt.show()
print('\nKey Observations:')
print('- Higher session duration and achievements correlate with higher engagement')
print('- Age shows minimal variation across engagement levels')
print('- Sessions per week appears evenly distributed across groups')
Key Observations: - Higher session duration and achievements correlate with higher engagement - Age shows minimal variation across engagement levels - Sessions per week appears evenly distributed across groups
In [6]:
# CORRELATION ANALYSIS
# Identifying relationships between numeric features
numeric_cols = ['Age', 'PlayTimeHours', 'InGamePurchases', 'SessionsPerWeek',
'AvgSessionDurationMinutes', 'PlayerLevel', 'AchievementsUnlocked']
plt.figure(figsize=(10, 8))
correlation_matrix = df[numeric_cols].corr()
sns.heatmap(correlation_matrix, annot=True, cmap='coolwarm', center=0,
fmt='.2f', linewidths=0.5)
plt.title('Feature Correlation Heatmap')
plt.tight_layout()
plt.show()
# Identify notable correlations
print('\n=== NOTABLE CORRELATIONS (|r| > 0.3) ===')
for i in range(len(numeric_cols)):
for j in range(i+1, len(numeric_cols)):
corr = correlation_matrix.iloc[i, j]
if abs(corr) > 0.3:
print(f'{numeric_cols[i]} vs {numeric_cols[j]}: r = {corr:.3f}')
=== NOTABLE CORRELATIONS (|r| > 0.3) ===
In [7]:
# DATA PREPROCESSING PIPELINE
# Step 1: Handle missing values
# Numeric columns: fill with median (robust to outliers)
# Categorical columns: fill with mode (most frequent value)
num_cols = df.select_dtypes(include=[np.number]).columns
df[num_cols] = df[num_cols].fillna(df[num_cols].median())
for col in df.select_dtypes(include=['object']).columns:
df[col] = df[col].fillna(df[col].mode()[0])
print('Step 1: Missing values handled')
print(f' Remaining missing values: {df.isna().sum().sum()}')
# Step 2: Encode target variable
# LabelEncoder converts categorical target to numeric (0, 1, 2)
le = LabelEncoder()
df['EngagementLevelEncoded'] = le.fit_transform(df['EngagementLevel'])
print(f'\nStep 2: Target encoded')
print(f' Mapping: {dict(zip(le.classes_, range(len(le.classes_))))}')
# Step 3: One-hot encode categorical features
# drop_first=True avoids multicollinearity (dummy variable trap)
categorical_features = ['Gender', 'Location', 'GameGenre', 'GameDifficulty']
df = pd.get_dummies(df, columns=categorical_features, drop_first=True)
print(f'\nStep 3: Categorical features one-hot encoded')
# Step 4: Prepare features (X) and target (y)
feature_cols = [col for col in df.columns
if col not in ['PlayerID', 'EngagementLevel', 'EngagementLevelEncoded']]
X = df[feature_cols]
y = df['EngagementLevelEncoded']
print(f'\nStep 4: Features prepared')
print(f' Number of features: {len(feature_cols)}')
# Step 5: Train/test split with stratification
# Stratification ensures class proportions are maintained in both sets
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=RANDOM_STATE, stratify=y
)
print(f'\nStep 5: Data split')
print(f' Training set: {X_train.shape[0]} samples')
print(f' Test set: {X_test.shape[0]} samples')
# Step 6: Feature scaling
# StandardScaler normalises features to zero mean and unit variance
# Essential for SVM which is sensitive to feature scales
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
print(f'\nStep 6: Features scaled using StandardScaler')
Step 1: Missing values handled
Remaining missing values: 0
Step 2: Target encoded
Mapping: {'High': 0, 'Low': 1, 'Medium': 2}
Step 3: Categorical features one-hot encoded
Step 4: Features prepared
Number of features: 17
Step 5: Data split
Training set: 32027 samples
Test set: 8007 samples
Step 6: Features scaled using StandardScaler
In [8]:
# BASELINE MODELS
# Training models with default parameters to establish performance benchmarks
print('=' * 60)
print('BASELINE MODEL PERFORMANCE')
print('=' * 60)
# Baseline Decision Tree
# Default parameters: no max_depth limit, gini criterion
print('\n--- Decision Tree (Default Parameters) ---')
dt = DecisionTreeClassifier(random_state=RANDOM_STATE)
dt.fit(X_train_scaled, y_train)
dt_pred = dt.predict(X_test_scaled)
print(f'Accuracy: {accuracy_score(y_test, dt_pred):.4f}')
print(f'\nClassification Report:')
print(classification_report(y_test, dt_pred, target_names=le.classes_))
# Baseline Linear SVM
# LinearSVC is faster than SVC for large datasets
print('\n--- Linear SVM (Default Parameters) ---')
svm_linear = LinearSVC(random_state=RANDOM_STATE, max_iter=5000, dual='auto')
svm_linear.fit(X_train_scaled, y_train)
svm_linear_pred = svm_linear.predict(X_test_scaled)
print(f'Accuracy: {accuracy_score(y_test, svm_linear_pred):.4f}')
print(f'\nClassification Report:')
print(classification_report(y_test, svm_linear_pred, target_names=le.classes_))
============================================================
BASELINE MODEL PERFORMANCE
============================================================
--- Decision Tree (Default Parameters) ---
Accuracy: 0.8374
Classification Report:
precision recall f1-score support
High 0.81 0.82 0.81 2067
Low 0.80 0.81 0.80 2065
Medium 0.87 0.86 0.87 3875
accuracy 0.84 8007
macro avg 0.83 0.83 0.83 8007
weighted avg 0.84 0.84 0.84 8007
--- Linear SVM (Default Parameters) ---
Accuracy: 0.8169
Classification Report:
precision recall f1-score support
High 0.85 0.84 0.85 2067
Low 0.79 0.71 0.75 2065
Medium 0.81 0.86 0.83 3875
accuracy 0.82 8007
macro avg 0.82 0.80 0.81 8007
weighted avg 0.82 0.82 0.82 8007
In [9]:
# HYPERPARAMETER TUNING: DECISION TREE
# Using GridSearchCV with 5-fold cross-validation
print('=' * 60)
print('DECISION TREE HYPERPARAMETER TUNING')
print('=' * 60)
# Define parameter grid
# max_depth: None allows unlimited growth, integers limit tree depth
# criterion: gini (default) vs entropy (information gain)
param_grid_dt = {
'max_depth': [5, 10, None],
'min_samples_split': [2, 5, 10],
'min_samples_leaf': [1, 5, 10],
'criterion': ['gini', 'entropy']
}
print(f'Parameter grid: {len(param_grid_dt["max_depth"])} x {len(param_grid_dt["min_samples_split"])} x '
f'{len(param_grid_dt["min_samples_leaf"])} x {len(param_grid_dt["criterion"])} = '
f'{3*3*3*2} combinations')
# Perform grid search with F1-macro scoring
# F1-macro treats all classes equally, important for imbalanced data
dt_grid = GridSearchCV(
DecisionTreeClassifier(random_state=RANDOM_STATE),
param_grid_dt,
scoring='f1_macro',
cv=5,
n_jobs=-1,
return_train_score=True
)
dt_grid.fit(X_train_scaled, y_train)
# Extract best model and predictions
dt_best = dt_grid.best_estimator_
dt_tuned_pred = dt_best.predict(X_test_scaled)
print(f'\nBest Parameters: {dt_grid.best_params_}')
print(f'Best Cross-Validation F1 Score: {dt_grid.best_score_:.4f}')
print(f'\nTest Set Performance:')
print(f'Accuracy: {accuracy_score(y_test, dt_tuned_pred):.4f}')
print(f'\nClassification Report:')
print(classification_report(y_test, dt_tuned_pred, target_names=le.classes_))
============================================================
DECISION TREE HYPERPARAMETER TUNING
============================================================
Parameter grid: 3 x 3 x 3 x 2 = 54 combinations
Best Parameters: {'criterion': 'entropy', 'max_depth': 10, 'min_samples_leaf': 10, 'min_samples_split': 2}
Best Cross-Validation F1 Score: 0.9012
Test Set Performance:
Accuracy: 0.9025
Classification Report:
precision recall f1-score support
High 0.91 0.88 0.90 2067
Low 0.89 0.87 0.88 2065
Medium 0.91 0.93 0.92 3875
accuracy 0.90 8007
macro avg 0.90 0.89 0.90 8007
weighted avg 0.90 0.90 0.90 8007
In [10]:
# DECISION TREE VISUALISATION
# Displaying the tree structure to understand decision rules
plt.figure(figsize=(20, 12))
plot_tree(
dt_best,
feature_names=feature_cols,
class_names=le.classes_,
filled=True,
rounded=True,
max_depth=3, # Limit display depth for readability
fontsize=9,
proportion=True
)
plt.title('Decision Tree Structure (Top 3 Levels)', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()
print('\nTree Structure Summary:')
print(f' Total Depth: {dt_best.get_depth()}')
print(f' Total Leaves: {dt_best.get_n_leaves()}')
print(f' Features Used: {sum(dt_best.feature_importances_ > 0)}')
Tree Structure Summary: Total Depth: 10 Total Leaves: 613 Features Used: 16
In [11]:
# HYPERPARAMETER TUNING: SUPPORT VECTOR MACHINE
# Using subsampling for efficiency (SVM is computationally expensive)
print('=' * 60)
print('SVM HYPERPARAMETER TUNING')
print('=' * 60)
# Subsample training data for faster tuning
# SVM has O(n²) to O(n³) complexity, so subsampling is essential
sss = StratifiedShuffleSplit(n_splits=1, test_size=0.8, random_state=RANDOM_STATE)
tune_idx, _ = next(sss.split(X_train_scaled, y_train))
X_tune = X_train_scaled[tune_idx]
y_tune = y_train.iloc[tune_idx]
print(f'Tuning on {len(X_tune)} samples ({len(X_tune)/len(X_train_scaled)*100:.0f}% of training data)')
# Define parameter grid
# C: Higher values = less regularisation, tighter fit
# kernel: linear for linearly separable, rbf for non-linear boundaries
param_grid_svm = {
'C': [0.1, 1, 10],
'kernel': ['linear', 'rbf'],
'gamma': ['scale'] # 'scale' uses 1/(n_features * X.var())
}
print(f'Parameter combinations: {3 * 2 * 1} = 6')
# Perform grid search
# probability=True enables predict_proba for ROC curves
svm_grid = GridSearchCV(
SVC(probability=True, random_state=RANDOM_STATE),
param_grid_svm,
scoring='f1_macro',
cv=3, # Reduced folds for speed
n_jobs=-1,
verbose=1
)
svm_grid.fit(X_tune, y_tune)
print(f'\nBest Parameters: {svm_grid.best_params_}')
print(f'Best Cross-Validation F1 Score: {svm_grid.best_score_:.4f}')
# Retrain on full training data with best parameters
print('\nRetraining on full training set...')
svc_best = SVC(**svm_grid.best_params_, probability=True, random_state=RANDOM_STATE)
svc_best.fit(X_train_scaled, y_train)
svc_tuned_pred = svc_best.predict(X_test_scaled)
print(f'\nTest Set Performance:')
print(f'Accuracy: {accuracy_score(y_test, svc_tuned_pred):.4f}')
print(f'\nClassification Report:')
print(classification_report(y_test, svc_tuned_pred, target_names=le.classes_))
============================================================
SVM HYPERPARAMETER TUNING
============================================================
Tuning on 6405 samples (20% of training data)
Parameter combinations: 6 = 6
Fitting 3 folds for each of 6 candidates, totalling 18 fits
Best Parameters: {'C': 1, 'gamma': 'scale', 'kernel': 'rbf'}
Best Cross-Validation F1 Score: 0.8378
Retraining on full training set...
Test Set Performance:
Accuracy: 0.8821
Classification Report:
precision recall f1-score support
High 0.91 0.84 0.88 2067
Low 0.88 0.83 0.86 2065
Medium 0.87 0.93 0.90 3875
accuracy 0.88 8007
macro avg 0.89 0.87 0.88 8007
weighted avg 0.88 0.88 0.88 8007
In [12]:
# MODEL EVALUATION: AUC-ROC SCORES
# Calculating area under ROC curve for multi-class classification
print('=' * 60)
print('MODEL EVALUATION: AUC-ROC')
print('=' * 60)
# Binarise labels for multi-class ROC calculation
classes = np.unique(y)
y_test_bin = label_binarize(y_test, classes=classes)
# Get probability predictions
dt_probs = dt_best.predict_proba(X_test_scaled)
svc_probs = svc_best.predict_proba(X_test_scaled)
# Calculate AUC-ROC using One-vs-Rest strategy
dt_auc = roc_auc_score(y_test_bin, dt_probs, multi_class='ovr')
svc_auc = roc_auc_score(y_test_bin, svc_probs, multi_class='ovr')
print(f'\nAUC-ROC Scores (One-vs-Rest):')
print(f' Tuned Decision Tree: {dt_auc:.4f}')
print(f' Tuned SVM: {svc_auc:.4f}')
# Interpretation
print(f'\nInterpretation:')
print(f' AUC > 0.9: Excellent discrimination')
print(f' AUC 0.8-0.9: Good discrimination')
print(f' AUC 0.7-0.8: Fair discrimination')
============================================================ MODEL EVALUATION: AUC-ROC ============================================================ AUC-ROC Scores (One-vs-Rest): Tuned Decision Tree: 0.9363 Tuned SVM: 0.9394 Interpretation: AUC > 0.9: Excellent discrimination AUC 0.8-0.9: Good discrimination AUC 0.7-0.8: Fair discrimination
In [13]:
# CONFUSION MATRICES
# Visualising classification performance for each class
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Decision Tree Confusion Matrix
ConfusionMatrixDisplay.from_predictions(
y_test, dt_tuned_pred,
display_labels=le.classes_,
ax=axes[0],
cmap='Blues'
)
axes[0].set_title('Tuned Decision Tree', fontsize=12, fontweight='bold')
# SVM Confusion Matrix
ConfusionMatrixDisplay.from_predictions(
y_test, svc_tuned_pred,
display_labels=le.classes_,
ax=axes[1],
cmap='Oranges'
)
axes[1].set_title('Tuned SVM', fontsize=12, fontweight='bold')
plt.suptitle('Confusion Matrices', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()
# Analyse misclassifications
print('\n=== MISCLASSIFICATION ANALYSIS ===')
for model_name, predictions in [('Decision Tree', dt_tuned_pred), ('SVM', svc_tuned_pred)]:
cm = confusion_matrix(y_test, predictions)
print(f'\n{model_name}:')
for i, label in enumerate(le.classes_):
correct = cm[i, i]
total = cm[i, :].sum()
print(f' {label}: {correct}/{total} correct ({correct/total*100:.1f}%)')
=== MISCLASSIFICATION ANALYSIS === Decision Tree: High: 1822/2067 correct (88.1%) Low: 1801/2065 correct (87.2%) Medium: 3603/3875 correct (93.0%) SVM: High: 1743/2067 correct (84.3%) Low: 1714/2065 correct (83.0%) Medium: 3606/3875 correct (93.1%)
In [14]:
# ROC CURVES
# Plotting Receiver Operating Characteristic curves for each class
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
colours = ['#1f77b4', '#ff7f0e', '#2ca02c']
for ax, probs, title in [
(axes[0], dt_probs, 'Decision Tree'),
(axes[1], svc_probs, 'SVM')
]:
# Plot ROC curve for each class
for i, (label, colour) in enumerate(zip(le.classes_, colours)):
fpr, tpr, _ = roc_curve(y_test_bin[:, i], probs[:, i])
roc_auc = auc(fpr, tpr)
ax.plot(fpr, tpr, color=colour, lw=2,
label=f'{label} (AUC = {roc_auc:.3f})')
# Plot diagonal reference line (random classifier)
ax.plot([0, 1], [0, 1], 'k--', lw=2, label='Random')
ax.set_xlabel('False Positive Rate')
ax.set_ylabel('True Positive Rate')
ax.set_title(f'{title} ROC Curves', fontweight='bold')
ax.legend(loc='lower right')
ax.grid(True, alpha=0.3)
plt.suptitle('ROC Curves by Class', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()
In [15]:
# FEATURE IMPORTANCE ANALYSIS
# Identifying which features contribute most to predictions
print('=' * 60)
print('FEATURE IMPORTANCE (Decision Tree)')
print('=' * 60)
# Extract and sort feature importances
importance_df = pd.DataFrame({
'Feature': feature_cols,
'Importance': dt_best.feature_importances_
}).sort_values('Importance', ascending=False)
# Display top 10 features
top_features = importance_df.head(10)
plt.figure(figsize=(10, 6))
bars = plt.barh(top_features['Feature'], top_features['Importance'], color='steelblue')
plt.xlabel('Importance Score')
plt.title('Top 10 Feature Importances (Tuned Decision Tree)', fontweight='bold')
plt.gca().invert_yaxis() # Highest importance at top
# Add value labels
for bar, val in zip(bars, top_features['Importance']):
plt.text(val + 0.01, bar.get_y() + bar.get_height()/2,
f'{val:.3f}', va='center')
plt.tight_layout()
plt.show()
print('\nTop 10 Features:')
print(top_features.to_string(index=False))
print('\nKey Insight:')
top_feature = importance_df.iloc[0]
print(f' {top_feature["Feature"]} is the most important predictor')
print(f' with an importance score of {top_feature["Importance"]:.4f}')
============================================================ FEATURE IMPORTANCE (Decision Tree) ============================================================
Top 10 Features:
Feature Importance
SessionsPerWeek 0.448070
AvgSessionDurationMinutes 0.438776
AchievementsUnlocked 0.043271
PlayerLevel 0.040715
PlayTimeHours 0.010952
Age 0.008686
Gender_Male 0.001482
GameDifficulty_Medium 0.001412
GameDifficulty_Hard 0.001083
GameGenre_RPG 0.001016
Key Insight:
SessionsPerWeek is the most important predictor
with an importance score of 0.4481
In [16]:
# CLUSTERING: FINDING OPTIMAL K
# Using Elbow Method and Silhouette Analysis
print('=' * 60)
print('K-MEANS CLUSTERING: OPTIMAL K SELECTION')
print('=' * 60)
# Select behavioural features for clustering
# Excluding categorical and ID features
cluster_features = ['PlayTimeHours', 'InGamePurchases', 'SessionsPerWeek',
'AvgSessionDurationMinutes', 'PlayerLevel', 'AchievementsUnlocked']
X_cluster = df[cluster_features].values
# Scale features for clustering
# K-Means uses Euclidean distance, so scaling is essential
cluster_scaler = StandardScaler()
X_cluster_scaled = cluster_scaler.fit_transform(X_cluster)
print(f'Clustering Features: {cluster_features}')
print(f'Number of samples: {len(X_cluster)}')
# Evaluate different k values
K_range = range(2, 11)
inertias = []
sil_scores = []
print('\nEvaluating k values...')
for k in K_range:
km = KMeans(n_clusters=k, random_state=RANDOM_STATE, n_init='auto')
km.fit(X_cluster_scaled)
inertias.append(km.inertia_)
sil_scores.append(silhouette_score(X_cluster_scaled, km.labels_))
# Determine optimal k using highest silhouette score
optimal_k = list(K_range)[np.argmax(sil_scores)]
print(f'\nOptimal k (highest silhouette score): {optimal_k}')
# Visualise Elbow and Silhouette plots
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Elbow plot (Inertia)
axes[0].plot(K_range, inertias, 'bo-', lw=2, markersize=8)
axes[0].axvline(x=optimal_k, color='r', linestyle='--', lw=2, label=f'Optimal k={optimal_k}')
axes[0].set_xlabel('Number of Clusters (k)')
axes[0].set_ylabel('Inertia (Within-cluster Sum of Squares)')
axes[0].set_title('Elbow Method', fontweight='bold')
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# Silhouette plot
axes[1].plot(K_range, sil_scores, 'go-', lw=2, markersize=8)
axes[1].axvline(x=optimal_k, color='r', linestyle='--', lw=2, label=f'Optimal k={optimal_k}')
axes[1].set_xlabel('Number of Clusters (k)')
axes[1].set_ylabel('Silhouette Score')
axes[1].set_title('Silhouette Analysis', fontweight='bold')
axes[1].legend()
axes[1].grid(True, alpha=0.3)
plt.suptitle('Optimal Cluster Selection', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()
# Print results table
print('\n=== ELBOW METHOD RESULTS ===')
for k, inertia, sil in zip(K_range, inertias, sil_scores):
marker = ' <-- OPTIMAL' if k == optimal_k else ''
print(f'k={k}: Inertia={inertia:,.0f}, Silhouette={sil:.4f}{marker}')
============================================================ K-MEANS CLUSTERING: OPTIMAL K SELECTION ============================================================ Clustering Features: ['PlayTimeHours', 'InGamePurchases', 'SessionsPerWeek', 'AvgSessionDurationMinutes', 'PlayerLevel', 'AchievementsUnlocked'] Number of samples: 40034 Evaluating k values... Optimal k (highest silhouette score): 10
=== ELBOW METHOD RESULTS === k=2: Inertia=210,181, Silhouette=0.1266 k=3: Inertia=191,888, Silhouette=0.1133 k=4: Inertia=177,413, Silhouette=0.1173 k=5: Inertia=150,159, Silhouette=0.1426 k=6: Inertia=141,504, Silhouette=0.1391 k=7: Inertia=133,280, Silhouette=0.1445 k=8: Inertia=127,159, Silhouette=0.1484 k=9: Inertia=123,496, Silhouette=0.1477 k=10: Inertia=120,523, Silhouette=0.1508 <-- OPTIMAL
In [17]:
# RUN K-MEANS WITH OPTIMAL K
print('=' * 60)
print(f'K-MEANS CLUSTERING (k={optimal_k})')
print('=' * 60)
# Fit final K-Means model
kmeans = KMeans(n_clusters=optimal_k, random_state=RANDOM_STATE, n_init='auto')
df['Cluster'] = kmeans.fit_predict(X_cluster_scaled)
# Calculate evaluation metrics
final_silhouette = silhouette_score(X_cluster_scaled, df['Cluster'])
final_inertia = kmeans.inertia_
print(f'\n=== CLUSTERING METRICS ===')
print(f'Silhouette Score: {final_silhouette:.4f}')
print(f'Inertia: {final_inertia:,.2f}')
# Interpret silhouette score
print(f'\n=== SILHOUETTE INTERPRETATION ===')
if final_silhouette > 0.7:
interpretation = 'Strong cluster structure - clusters are well-separated'
elif final_silhouette > 0.5:
interpretation = 'Reasonable cluster structure - clusters are moderately separated'
elif final_silhouette > 0.25:
interpretation = 'Weak cluster structure - clusters overlap significantly'
else:
interpretation = 'No substantial cluster structure - consider alternative methods'
print(f'{interpretation}')
# Display cluster distribution
print(f'\n=== CLUSTER DISTRIBUTION ===')
for cluster, count in df['Cluster'].value_counts().sort_index().items():
print(f'Cluster {cluster}: {count:,} players ({count/len(df)*100:.1f}%)')
============================================================ K-MEANS CLUSTERING (k=10) ============================================================ === CLUSTERING METRICS === Silhouette Score: 0.1508 Inertia: 120,522.74 === SILHOUETTE INTERPRETATION === No substantial cluster structure - consider alternative methods === CLUSTER DISTRIBUTION === Cluster 0: 5,198 players (13.0%) Cluster 1: 5,391 players (13.5%) Cluster 2: 5,251 players (13.1%) Cluster 3: 5,409 players (13.5%) Cluster 4: 5,430 players (13.6%) Cluster 5: 5,315 players (13.3%) Cluster 6: 2,080 players (5.2%) Cluster 7: 2,014 players (5.0%) Cluster 8: 2,003 players (5.0%) Cluster 9: 1,943 players (4.9%)
In [18]:
# DETAILED CLUSTER PROFILING
# Analysing characteristics of each player segment
print('=' * 60)
print('CLUSTER PROFILES')
print('=' * 60)
for cluster in range(optimal_k):
cluster_data = df[df['Cluster'] == cluster]
print(f'\n--- CLUSTER {cluster} ({len(cluster_data):,} players, {len(cluster_data)/len(df)*100:.1f}%) ---')
# Behavioural characteristics
print(f' Play Time: {cluster_data["PlayTimeHours"].mean():.1f} hrs (avg)')
print(f' Sessions/Week: {cluster_data["SessionsPerWeek"].mean():.1f}')
print(f' Session Duration: {cluster_data["AvgSessionDurationMinutes"].mean():.0f} mins')
print(f' Player Level: {cluster_data["PlayerLevel"].mean():.0f}')
print(f' Achievements: {cluster_data["AchievementsUnlocked"].mean():.1f}')
print(f' Purchase Rate: {cluster_data["InGamePurchases"].mean()*100:.1f}%')
# Engagement breakdown
engagement = cluster_data['EngagementLevel'].value_counts(normalize=True)
print(f' Engagement Mix: High {engagement.get("High", 0)*100:.0f}%, '
f'Medium {engagement.get("Medium", 0)*100:.0f}%, '
f'Low {engagement.get("Low", 0)*100:.0f}%')
# Cluster centroids in original scale
print('\n=== CLUSTER CENTROIDS (Original Scale) ===')
centroids_original = cluster_scaler.inverse_transform(kmeans.cluster_centers_)
centroids_df = pd.DataFrame(centroids_original, columns=cluster_features).round(2)
print(centroids_df)
============================================================ CLUSTER PROFILES ============================================================ --- CLUSTER 0 (5,198 players, 13.0%) --- Play Time: 8.9 hrs (avg) Sessions/Week: 14.1 Session Duration: 135 mins Player Level: 35 Achievements: 15.6 Purchase Rate: 0.0% Engagement Mix: High 69%, Medium 27%, Low 3% --- CLUSTER 1 (5,391 players, 13.5%) --- Play Time: 12.6 hrs (avg) Sessions/Week: 10.0 Session Duration: 57 mins Player Level: 74 Achievements: 12.0 Purchase Rate: 0.0% Engagement Mix: High 10%, Medium 55%, Low 35% --- CLUSTER 2 (5,251 players, 13.1%) --- Play Time: 8.3 hrs (avg) Sessions/Week: 5.5 Session Duration: 131 mins Player Level: 72 Achievements: 30.9 Purchase Rate: 0.0% Engagement Mix: High 19%, Medium 56%, Low 24% --- CLUSTER 3 (5,409 players, 13.5%) --- Play Time: 17.9 hrs (avg) Sessions/Week: 13.7 Session Duration: 92 mins Player Level: 56 Achievements: 37.0 Purchase Rate: 0.0% Engagement Mix: High 43%, Medium 50%, Low 7% --- CLUSTER 4 (5,430 players, 13.6%) --- Play Time: 17.8 hrs (avg) Sessions/Week: 4.5 Session Duration: 103 mins Player Level: 29 Achievements: 20.1 Purchase Rate: 0.0% Engagement Mix: High 7%, Medium 48%, Low 45% --- CLUSTER 5 (5,315 players, 13.3%) --- Play Time: 6.4 hrs (avg) Sessions/Week: 8.9 Session Duration: 53 mins Player Level: 31 Achievements: 31.5 Purchase Rate: 0.0% Engagement Mix: High 6%, Medium 54%, Low 40% --- CLUSTER 6 (2,080 players, 5.2%) --- Play Time: 7.5 hrs (avg) Sessions/Week: 13.7 Session Duration: 95 mins Player Level: 50 Achievements: 36.1 Purchase Rate: 100.0% Engagement Mix: High 44%, Medium 47%, Low 9% --- CLUSTER 7 (2,014 players, 5.0%) --- Play Time: 6.7 hrs (avg) Sessions/Week: 5.3 Session Duration: 99 mins Player Level: 51 Achievements: 14.6 Purchase Rate: 100.0% Engagement Mix: High 10%, Medium 49%, Low 41% --- CLUSTER 8 (2,003 players, 5.0%) --- Play Time: 17.4 hrs (avg) Sessions/Week: 5.4 Session Duration: 88 mins Player Level: 50 Achievements: 34.0 Purchase Rate: 100.0% Engagement Mix: High 10%, Medium 50%, Low 40% --- CLUSTER 9 (1,943 players, 4.9%) --- Play Time: 16.5 hrs (avg) Sessions/Week: 13.7 Session Duration: 95 mins Player Level: 48 Achievements: 12.7 Purchase Rate: 100.0% Engagement Mix: High 42%, Medium 47%, Low 11% === CLUSTER CENTROIDS (Original Scale) === PlayTimeHours InGamePurchases SessionsPerWeek AvgSessionDurationMinutes \ 0 8.91 0.0 14.12 134.60 1 12.60 0.0 10.04 56.81 2 8.29 0.0 5.49 131.21 3 17.87 0.0 13.72 92.50 4 17.83 0.0 4.55 103.19 5 6.43 0.0 8.89 52.61 6 7.51 1.0 13.70 95.48 7 6.69 1.0 5.31 98.97 8 17.40 1.0 5.45 88.07 9 16.50 1.0 13.66 95.38 PlayerLevel AchievementsUnlocked 0 35.31 15.63 1 73.51 11.96 2 71.82 30.83 3 56.45 37.02 4 28.80 20.07 5 31.40 31.57 6 50.32 36.07 7 51.42 14.57 8 50.08 34.00 9 48.19 12.73
In [19]:
# CLUSTER VISUALISATION USING PCA
# Reducing dimensions for 2D visualisation
# Apply PCA for dimensionality reduction
pca = PCA(n_components=2)
X_pca = pca.fit_transform(X_cluster_scaled)
plt.figure(figsize=(10, 8))
scatter = plt.scatter(X_pca[:, 0], X_pca[:, 1],
c=df['Cluster'], cmap='viridis',
alpha=0.5, s=10)
# Plot cluster centroids
centroids_pca = pca.transform(kmeans.cluster_centers_)
plt.scatter(centroids_pca[:, 0], centroids_pca[:, 1],
c='red', marker='X', s=200, edgecolors='black', linewidths=2,
label='Centroids')
plt.colorbar(scatter, label='Cluster')
plt.xlabel(f'PC1 ({pca.explained_variance_ratio_[0]*100:.1f}% variance)')
plt.ylabel(f'PC2 ({pca.explained_variance_ratio_[1]*100:.1f}% variance)')
plt.title('Player Clusters (PCA Projection)', fontsize=14, fontweight='bold')
plt.legend()
plt.tight_layout()
plt.show()
print(f'\nPCA Explained Variance:')
print(f' PC1: {pca.explained_variance_ratio_[0]*100:.1f}%')
print(f' PC2: {pca.explained_variance_ratio_[1]*100:.1f}%')
print(f' Total: {sum(pca.explained_variance_ratio_)*100:.1f}%')
PCA Explained Variance: PC1: 16.9% PC2: 16.8% Total: 33.7%
In [20]:
# CLUSTER VS ENGAGEMENT ANALYSIS
# Comparing unsupervised clusters with engagement labels
print('=' * 60)
print('CLUSTER VS ENGAGEMENT LEVEL')
print('=' * 60)
# Create cross-tabulation
cross_tab = pd.crosstab(df['Cluster'], df['EngagementLevel'], normalize='index')
print('\nRow Proportions (% of each cluster):')
print((cross_tab * 100).round(1))
# Visualise as stacked bar chart
ax = cross_tab.plot(kind='bar', stacked=True, figsize=(12, 6),
colormap='RdYlGn', edgecolor='black')
plt.title('Engagement Level Distribution by Cluster', fontsize=14, fontweight='bold')
plt.xlabel('Cluster')
plt.ylabel('Proportion')
plt.legend(title='Engagement Level', bbox_to_anchor=(1.02, 1))
plt.xticks(rotation=0)
# Add percentage labels
for container in ax.containers:
ax.bar_label(container, fmt='%.0f%%', label_type='center', fontsize=8)
plt.tight_layout()
plt.show()
# Identify high-risk and high-value clusters
print('\n=== CLUSTER INSIGHTS ===')
for cluster in range(optimal_k):
high_pct = cross_tab.loc[cluster, 'High'] * 100 if 'High' in cross_tab.columns else 0
low_pct = cross_tab.loc[cluster, 'Low'] * 100 if 'Low' in cross_tab.columns else 0
if high_pct > 40:
print(f'Cluster {cluster}: HIGH-VALUE segment ({high_pct:.0f}% high engagement)')
elif low_pct > 40:
print(f'Cluster {cluster}: AT-RISK segment ({low_pct:.0f}% low engagement)')
============================================================ CLUSTER VS ENGAGEMENT LEVEL ============================================================ Row Proportions (% of each cluster): EngagementLevel High Low Medium Cluster 0 69.2 3.4 27.4 1 10.2 34.9 54.9 2 19.5 24.0 56.5 3 43.2 7.0 49.8 4 6.9 45.1 48.0 5 6.0 40.2 53.8 6 44.2 8.8 47.0 7 10.0 41.1 48.9 8 10.1 40.1 49.8 9 41.7 11.5 46.8
=== CLUSTER INSIGHTS === Cluster 0: HIGH-VALUE segment (69% high engagement) Cluster 3: HIGH-VALUE segment (43% high engagement) Cluster 4: AT-RISK segment (45% low engagement) Cluster 5: AT-RISK segment (40% low engagement) Cluster 6: HIGH-VALUE segment (44% high engagement) Cluster 7: AT-RISK segment (41% low engagement) Cluster 8: AT-RISK segment (40% low engagement) Cluster 9: HIGH-VALUE segment (42% high engagement)
In [21]:
# SUPERVISED VS UNSUPERVISED LEARNING COMPARISON
print('=' * 60)
print('SUPERVISED VS UNSUPERVISED LEARNING COMPARISON')
print('=' * 60)
print('\n=== SUPERVISED LEARNING (Decision Tree & SVM) ===')
print('\nStrengths:')
print(' + High accuracy in predicting known engagement levels')
print(f' + Best model achieved {accuracy_score(y_test, dt_tuned_pred)*100:.1f}% accuracy')
print(' + Clear feature importance identifies key predictors')
print(' + Decision boundaries enable automated classification')
print(' + Tuned Decision Tree provides interpretable rules')
print('\nLimitations:')
print(' - Requires labelled training data (engagement levels)')
print(' - Cannot discover unknown patterns or segments')
print(' - May overfit to training data distribution')
print(' - SVM computational cost limits scalability')
print('\n=== UNSUPERVISED LEARNING (K-Means Clustering) ===')
print('\nStrengths:')
print(' + Discovers natural player segments without labels')
print(' + Reveals hidden behavioural patterns')
print(f' + Identified {optimal_k} distinct player groups')
print(' + Useful for personalised marketing strategies')
print(' + No labelled data required')
print('\nLimitations:')
print(f' - Low silhouette score ({final_silhouette:.3f}) indicates weak separation')
print(' - Cluster interpretation requires domain expertise')
print(' - Sensitive to feature scaling and k selection')
print(' - Cannot directly predict engagement levels')
print('\n=== KEY INSIGHT ===')
print('Supervised learning excels at predicting engagement levels with high accuracy,')
print('whilst unsupervised learning reveals player segments that may not align with')
print('engagement labels but offer unique value for targeted business strategies.')
print('\nRecommendation: Use both approaches together - supervised for prediction,')
print('unsupervised for customer segmentation and personalisation.')
============================================================ SUPERVISED VS UNSUPERVISED LEARNING COMPARISON ============================================================ === SUPERVISED LEARNING (Decision Tree & SVM) === Strengths: + High accuracy in predicting known engagement levels + Best model achieved 90.2% accuracy + Clear feature importance identifies key predictors + Decision boundaries enable automated classification + Tuned Decision Tree provides interpretable rules Limitations: - Requires labelled training data (engagement levels) - Cannot discover unknown patterns or segments - May overfit to training data distribution - SVM computational cost limits scalability === UNSUPERVISED LEARNING (K-Means Clustering) === Strengths: + Discovers natural player segments without labels + Reveals hidden behavioural patterns + Identified 10 distinct player groups + Useful for personalised marketing strategies + No labelled data required Limitations: - Low silhouette score (0.151) indicates weak separation - Cluster interpretation requires domain expertise - Sensitive to feature scaling and k selection - Cannot directly predict engagement levels === KEY INSIGHT === Supervised learning excels at predicting engagement levels with high accuracy, whilst unsupervised learning reveals player segments that may not align with engagement labels but offer unique value for targeted business strategies. Recommendation: Use both approaches together - supervised for prediction, unsupervised for customer segmentation and personalisation.
In [22]:
# FINAL MODEL COMPARISON SUMMARY
print('\n' + '=' * 60)
print('FINAL MODEL COMPARISON SUMMARY')
print('=' * 60)
# Build comprehensive results table
models = {
'Baseline Decision Tree': dt_pred,
'Baseline SVM': svm_linear_pred,
'Tuned Decision Tree': dt_tuned_pred,
'Tuned SVM': svc_tuned_pred
}
results_df = pd.DataFrame([
{
'Model': name,
'Accuracy': accuracy_score(y_test, pred),
'Precision': precision_score(y_test, pred, average='macro'),
'Recall': recall_score(y_test, pred, average='macro'),
'F1-Score': f1_score(y_test, pred, average='macro')
}
for name, pred in models.items()
]).round(4)
print('\n--- CLASSIFICATION MODEL PERFORMANCE ---')
print(results_df.to_string(index=False))
# AUC-ROC for tuned models
print(f'\n--- AUC-ROC SCORES (Tuned Models) ---')
print(f'Decision Tree: {dt_auc:.4f}')
print(f'SVM: {svc_auc:.4f}')
# Best model identification
best = results_df.loc[results_df['F1-Score'].idxmax()]
print(f'\n--- BEST PERFORMING MODEL ---')
print(f'Model: {best["Model"]}')
print(f'Accuracy: {best["Accuracy"]:.2%}')
print(f'F1-Score: {best["F1-Score"]:.4f}')
# Tuning impact
dt_base = results_df[results_df['Model']=='Baseline Decision Tree']['F1-Score'].values[0]
dt_tuned = results_df[results_df['Model']=='Tuned Decision Tree']['F1-Score'].values[0]
svm_base = results_df[results_df['Model']=='Baseline SVM']['F1-Score'].values[0]
svm_tuned = results_df[results_df['Model']=='Tuned SVM']['F1-Score'].values[0]
print(f'\n--- HYPERPARAMETER TUNING IMPACT ---')
print(f'Decision Tree: {dt_base:.4f} → {dt_tuned:.4f} ({(dt_tuned/dt_base-1)*100:+.1f}%)')
print(f'SVM: {svm_base:.4f} → {svm_tuned:.4f} ({(svm_tuned/svm_base-1)*100:+.1f}%)')
# Clustering summary
print(f'\n--- CLUSTERING RESULTS ---')
print(f'Algorithm: K-Means (k={optimal_k})')
print(f'Silhouette Score: {final_silhouette:.4f}')
print(f'Inertia: {final_inertia:,.0f}')
# Best hyperparameters
print(f'\n--- OPTIMAL HYPERPARAMETERS ---')
print(f'Decision Tree: {dt_grid.best_params_}')
print(f'SVM: {svm_grid.best_params_}')
============================================================
FINAL MODEL COMPARISON SUMMARY
============================================================
--- CLASSIFICATION MODEL PERFORMANCE ---
Model Accuracy Precision Recall F1-Score
Baseline Decision Tree 0.8374 0.8274 0.8297 0.8285
Baseline SVM 0.8169 0.8181 0.8044 0.8103
Tuned Decision Tree 0.9025 0.9015 0.8945 0.8978
Tuned SVM 0.8821 0.8875 0.8680 0.8767
--- AUC-ROC SCORES (Tuned Models) ---
Decision Tree: 0.9363
SVM: 0.9394
--- BEST PERFORMING MODEL ---
Model: Tuned Decision Tree
Accuracy: 90.25%
F1-Score: 0.8978
--- HYPERPARAMETER TUNING IMPACT ---
Decision Tree: 0.8285 → 0.8978 (+8.4%)
SVM: 0.8103 → 0.8767 (+8.2%)
--- CLUSTERING RESULTS ---
Algorithm: K-Means (k=10)
Silhouette Score: 0.1508
Inertia: 120,523
--- OPTIMAL HYPERPARAMETERS ---
Decision Tree: {'criterion': 'entropy', 'max_depth': 10, 'min_samples_leaf': 10, 'min_samples_split': 2}
SVM: {'C': 1, 'gamma': 'scale', 'kernel': 'rbf'}
In [23]:
# BUSINESS RECOMMENDATIONS
print('=' * 60)
print('BUSINESS RECOMMENDATIONS')
print('=' * 60)
print('\n=== PREDICTIVE MODELLING ===')
print('1. Deploy the tuned Decision Tree classifier for real-time')
print(' engagement prediction due to its high accuracy and interpretability.')
print(f' Expected accuracy: {accuracy_score(y_test, dt_tuned_pred)*100:.1f}%')
print('\n2. Monitor key predictive features identified by the model:')
for i, row in importance_df.head(5).iterrows():
print(f' - {row["Feature"]}: {row["Importance"]*100:.1f}% importance')
print('\n=== PLAYER RETENTION STRATEGIES ===')
print('3. Focus retention efforts on at-risk player segments:')
for cluster in range(optimal_k):
cluster_data = df[df['Cluster'] == cluster]
low_pct = (cluster_data['EngagementLevel'] == 'Low').mean() * 100
if low_pct > 30:
print(f' - Cluster {cluster}: {low_pct:.0f}% low engagement - target with re-engagement campaigns')
print('\n4. Reward high-value player segments to maintain engagement:')
for cluster in range(optimal_k):
cluster_data = df[df['Cluster'] == cluster]
high_pct = (cluster_data['EngagementLevel'] == 'High').mean() * 100
if high_pct > 30:
print(f' - Cluster {cluster}: {high_pct:.0f}% high engagement - offer loyalty rewards')
print('\n=== GAME DESIGN INSIGHTS ===')
print('5. Session duration and achievements are strong engagement indicators.')
print(' Consider enhancing achievement systems and session-based rewards.')
print('\n6. The weak cluster separation suggests player behaviours are diverse.')
print(' Personalised experiences may be more effective than broad segmentation.')
print('\n' + '=' * 60)
print('ANALYSIS COMPLETE')
print('=' * 60)
============================================================ BUSINESS RECOMMENDATIONS ============================================================ === PREDICTIVE MODELLING === 1. Deploy the tuned Decision Tree classifier for real-time engagement prediction due to its high accuracy and interpretability. Expected accuracy: 90.2% 2. Monitor key predictive features identified by the model: - SessionsPerWeek: 44.8% importance - AvgSessionDurationMinutes: 43.9% importance - AchievementsUnlocked: 4.3% importance - PlayerLevel: 4.1% importance - PlayTimeHours: 1.1% importance === PLAYER RETENTION STRATEGIES === 3. Focus retention efforts on at-risk player segments: - Cluster 1: 35% low engagement - target with re-engagement campaigns - Cluster 4: 45% low engagement - target with re-engagement campaigns - Cluster 5: 40% low engagement - target with re-engagement campaigns - Cluster 7: 41% low engagement - target with re-engagement campaigns - Cluster 8: 40% low engagement - target with re-engagement campaigns 4. Reward high-value player segments to maintain engagement: - Cluster 0: 69% high engagement - offer loyalty rewards - Cluster 3: 43% high engagement - offer loyalty rewards - Cluster 6: 44% high engagement - offer loyalty rewards - Cluster 9: 42% high engagement - offer loyalty rewards === GAME DESIGN INSIGHTS === 5. Session duration and achievements are strong engagement indicators. Consider enhancing achievement systems and session-based rewards. 6. The weak cluster separation suggests player behaviours are diverse. Personalised experiences may be more effective than broad segmentation. ============================================================ ANALYSIS COMPLETE ============================================================