Σε αυτήν την εργασία αναλαμβάνουμε τον ρόλο data scientists σε ογκολογικό κέντρο. Στόχος μας είναι η κατασκευή ενός μοντέλου decision support για τους γιατρούς, το οποίο θα προβλέπει αν ένας όγκος είναι καλοήθης (benign) ή κακοήθης (malignant) βάσει κυτταρολογικών χαρακτηριστικών από βιοψίες.
Τα κριτήρια επιτυχίας δεν είναι μόνο η ακρίβεια (accuracy), αλλά και:
library(ggplot2)
library(dplyr)
library(tidyr)
library(mlbench)
library(randomForest)
library(xgboost)
library(caret)
library(pROC)
set.seed(42)data("BreastCancer", package = "mlbench")
bc <- BreastCancer
# Καθαρισμός: αφαίρεση ID, χειρισμός missing values, μετατροπή σε numeric
bc$Id <- NULL
bc <- na.omit(bc)
bc[, 1:9] <- lapply(bc[, 1:9], function(x) as.numeric(as.character(x)))
# Επισκόπηση
str(bc)## 'data.frame': 683 obs. of 10 variables:
## $ Cl.thickness : num 5 5 3 6 4 8 1 2 2 4 ...
## $ Cell.size : num 1 4 1 8 1 10 1 1 1 2 ...
## $ Cell.shape : num 1 4 1 8 1 10 1 2 1 1 ...
## $ Marg.adhesion : num 1 5 1 1 3 8 1 1 1 1 ...
## $ Epith.c.size : num 2 7 2 3 2 7 2 2 2 2 ...
## $ Bare.nuclei : num 1 10 2 4 1 10 10 1 1 1 ...
## $ Bl.cromatin : num 3 3 3 3 3 9 3 3 1 2 ...
## $ Normal.nucleoli: num 1 2 1 7 1 7 1 1 1 1 ...
## $ Mitoses : num 1 1 1 1 1 1 1 1 5 1 ...
## $ Class : Factor w/ 2 levels "benign","malignant": 1 1 1 1 1 2 1 1 1 1 ...
## - attr(*, "na.action")= 'omit' Named int [1:16] 24 41 140 146 159 165 236 250 276 293 ...
## ..- attr(*, "names")= chr [1:16] "24" "41" "140" "146" ...
## Κατανομή κλάσεων:
##
## benign malignant
## 444 239
## Ποσοστά:
##
## benign malignant
## 65 35
Παρατήρηση: Το dataset είναι ελαφρά ανισόρροπο (~65% benign, ~35% malignant). Αυτό είναι σημαντικό να το λαμβάνουμε υπόψη κατά την αξιολόγηση.
Χρησιμοποιούμε stratified split ώστε η κατανομή των κλάσεων να διατηρείται τόσο στο training όσο και στο test set.
train_idx <- createDataPartition(bc$Class, p = 0.70, list = FALSE)
train_data <- bc[train_idx, ]
test_data <- bc[-train_idx, ]
cat("Training set:", nrow(train_data), "παρατηρήσεις\n")## Training set: 479 παρατηρήσεις
## Test set: 204 παρατηρήσεις
##
## Κατανομή κλάσεων στο training set:
##
## benign malignant
## 64.9 35.1
##
## Κατανομή κλάσεων στο test set:
##
## benign malignant
## 65.2 34.8
Εκπαιδεύουμε ένα Random Forest με ntree = 500 δέντρα.
Ορίζουμε importance = TRUE ώστε να μπορούμε αργότερα να
εξετάσουμε ποια χαρακτηριστικά είναι πιο σημαντικά.
rf_model <- randomForest(
Class ~ .,
data = train_data,
ntree = 500,
importance = TRUE,
seed = 42
)
print(rf_model)##
## Call:
## randomForest(formula = Class ~ ., data = train_data, ntree = 500, importance = TRUE, seed = 42)
## Type of random forest: classification
## Number of trees: 500
## No. of variables tried at each split: 3
##
## OOB estimate of error rate: 3.34%
## Confusion matrix:
## benign malignant class.error
## benign 301 10 0.03215434
## malignant 6 162 0.03571429
Το Out-of-Bag (OOB) error που εμφανίζεται είναι μια αμερόληπτη εκτίμηση του σφάλματος γενίκευσης, χωρίς να χρειαστεί ξεχωριστό validation set.
# Προβλέψεις κλάσης
rf_pred_class <- predict(rf_model, newdata = test_data, type = "class")
# Πιθανότητες για το AUC
rf_pred_prob <- predict(rf_model, newdata = test_data, type = "prob")[, "malignant"]
# Confusion Matrix
rf_cm <- confusionMatrix(
rf_pred_class,
test_data$Class,
positive = "malignant"
)
print(rf_cm)## Confusion Matrix and Statistics
##
## Reference
## Prediction benign malignant
## benign 131 2
## malignant 2 69
##
## Accuracy : 0.9804
## 95% CI : (0.9506, 0.9946)
## No Information Rate : 0.652
## P-Value [Acc > NIR] : <2e-16
##
## Kappa : 0.9568
##
## Mcnemar's Test P-Value : 1
##
## Sensitivity : 0.9718
## Specificity : 0.9850
## Pos Pred Value : 0.9718
## Neg Pred Value : 0.9850
## Prevalence : 0.3480
## Detection Rate : 0.3382
## Detection Prevalence : 0.3480
## Balanced Accuracy : 0.9784
##
## 'Positive' Class : malignant
##
rf_roc <- roc(
response = test_data$Class,
predictor = rf_pred_prob,
levels = c("benign", "malignant")
)
cat("Random Forest AUC:", round(auc(rf_roc), 4), "\n")## Random Forest AUC: 0.9982
rf_accuracy <- rf_cm$overall["Accuracy"]
rf_sensitivity <- rf_cm$byClass["Sensitivity"]
rf_specificity <- rf_cm$byClass["Specificity"]
rf_auc_val <- as.numeric(auc(rf_roc))
cat("=== Random Forest — Μετρικές Απόδοσης ===\n")## === Random Forest — Μετρικές Απόδοσης ===
## Accuracy: 0.9804 (98.04%)
## Sensitivity: 0.9718 (97.18%)
## Specificity: 0.9850 (98.50%)
## AUC: 0.9982
importance_df <- as.data.frame(importance(rf_model))
importance_df$Feature <- rownames(importance_df)
importance_df <- importance_df[order(-importance_df$MeanDecreaseGini), ]
cat("Top 3 Features (MeanDecreaseGini):\n")## Top 3 Features (MeanDecreaseGini):
1. Accuracy που επιτεύχθηκε:
## Accuracy: 98.04%
2. Top-3 Features:
Τα τρία πιο σημαντικά χαρακτηριστικά σύμφωνα με το Mean Decrease Gini είναι:
Αυτά τα χαρακτηριστικά είναι συνεπή με την κλινική πρακτική: οι κακοήθεις όγκοι χαρακτηρίζονται από ανομοιομορφία κυττάρων και γυμνούς πυρήνες.
3. Είναι ~97% accuracy αρκετό σε ιατρικό context;
Όχι απαραίτητα. Σε ιατρικό context, η sensitivity (recall για malignant) έχει μεγαλύτερη σημασία από το γενικό accuracy. Ένα False Negative (κακοήθης όγκος που ταξινομείται ως καλοήθης) έχει πολύ σοβαρότερες συνέπειες από ένα False Positive. Επομένως, προτεραιότητα είναι η υψηλή sensitivity, ακόμα κι αν αυτό κοστίζει λίγο σε specificity.
Το XGBoost απαιτεί matrices αντί για data frames, και αριθμητικό target (0/1).
# Features σε matrix
X_train <- as.matrix(train_data[, 1:9])
X_test <- as.matrix(test_data[, 1:9])
# Target: malignant = 1, benign = 0
y_train <- ifelse(train_data$Class == "malignant", 1, 0)
y_test <- ifelse(test_data$Class == "malignant", 1, 0)
# DMatrix objects (βέλτιστο format για XGBoost)
dtrain <- xgb.DMatrix(data = X_train, label = y_train)
dtest <- xgb.DMatrix(data = X_test, label = y_test)
cat("Training matrix:", nrow(X_train), "x", ncol(X_train), "\n")## Training matrix: 479 x 9
## Test matrix: 204 x 9
Χρησιμοποιούμε early_stopping_rounds = 20 για να
αποτρέψουμε overfitting: αν δεν βελτιωθεί το validation error για 20
συνεχόμενους γύρους, σταματάει η εκπαίδευση.
xgb_params <- list(
objective = "binary:logistic",
eval_metric = "logloss",
max_depth = 4,
eta = 0.1,
subsample = 0.8,
colsample_bytree = 0.8
)
set.seed(42)
xgb_model <- xgb.train(
params = xgb_params,
data = dtrain,
nrounds = 500,
watchlist = list(train = dtrain, test = dtest),
early_stopping_rounds = 20,
verbose = 0
)
best_iter <- xgb_model$best_iteration
best_score <- as.numeric(xgb_model$evaluation_log[best_iter, "test_logloss"])
cat("Βέλτιστος αριθμός γύρων (best iteration):", best_iter, "\n")## Βέλτιστος αριθμός γύρων (best iteration):
## Best test logloss:
# Πιθανότητες
xgb_pred_prob <- predict(xgb_model, dtest)
# Κλάσεις με threshold 0.5
xgb_pred_class <- factor(
ifelse(xgb_pred_prob > 0.5, "malignant", "benign"),
levels = c("benign", "malignant")
)
# Confusion Matrix
xgb_cm <- confusionMatrix(
xgb_pred_class,
test_data$Class,
positive = "malignant"
)
print(xgb_cm)## Confusion Matrix and Statistics
##
## Reference
## Prediction benign malignant
## benign 131 3
## malignant 2 68
##
## Accuracy : 0.9755
## 95% CI : (0.9437, 0.992)
## No Information Rate : 0.652
## P-Value [Acc > NIR] : <2e-16
##
## Kappa : 0.9458
##
## Mcnemar's Test P-Value : 1
##
## Sensitivity : 0.9577
## Specificity : 0.9850
## Pos Pred Value : 0.9714
## Neg Pred Value : 0.9776
## Prevalence : 0.3480
## Detection Rate : 0.3333
## Detection Prevalence : 0.3431
## Balanced Accuracy : 0.9714
##
## 'Positive' Class : malignant
##
# AUC
xgb_roc <- roc(
response = test_data$Class,
predictor = xgb_pred_prob,
levels = c("benign", "malignant")
)
cat("\nXGBoost AUC:", round(auc(xgb_roc), 4), "\n")##
## XGBoost AUC: 0.9977
xgb_accuracy <- xgb_cm$overall["Accuracy"]
xgb_sensitivity <- xgb_cm$byClass["Sensitivity"]
xgb_specificity <- xgb_cm$byClass["Specificity"]
xgb_auc_val <- as.numeric(auc(xgb_roc))
comparison <- data.frame(
Model = c("Random Forest", "XGBoost"),
Accuracy = round(c(rf_accuracy, xgb_accuracy), 4),
Sensitivity = round(c(rf_sensitivity, xgb_sensitivity), 4),
Specificity = round(c(rf_specificity, xgb_specificity), 4),
AUC = round(c(rf_auc_val, xgb_auc_val), 4)
)
print(comparison)## Model Accuracy Sensitivity Specificity AUC
## 1 Random Forest 0.9804 0.9718 0.985 0.9982
## 2 XGBoost 0.9755 0.9577 0.985 0.9977
# Οπτικοποίηση σύγκρισης
comp_long <- pivot_longer(
comparison,
cols = -Model,
names_to = "Metric",
values_to = "Value"
)
ggplot(comp_long, aes(x = Metric, y = Value, fill = Model)) +
geom_bar(stat = "identity", position = "dodge", width = 0.6) +
geom_text(
aes(label = sprintf("%.3f", Value)),
position = position_dodge(width = 0.6),
vjust = -0.4, size = 3.5
) +
scale_fill_manual(values = c("steelblue", "tomato")) +
scale_y_continuous(limits = c(0, 1.05), labels = scales::percent) +
labs(
title = "Σύγκριση Μοντέλων: Random Forest vs XGBoost",
subtitle = "Μετρικές αξιολόγησης στο test set",
x = "Μετρική",
y = "Τιμή",
fill = "Μοντέλο"
) +
theme_minimal(base_size = 13) +
theme(legend.position = "top")Δοκιμάζουμε τρία διαφορετικά eta για να δούμε πώς
επηρεάζει τον αριθμό γύρων εκπαίδευσης.
eta_values <- c(0.01, 0.1, 0.3)
eta_results <- list()
for (eta_val in eta_values) {
params_eta <- list(
objective = "binary:logistic",
eval_metric = "logloss",
max_depth = 4,
eta = eta_val,
subsample = 0.8,
colsample_bytree = 0.8
)
set.seed(42)
model_eta <- xgb.train(
params = params_eta,
data = dtrain,
nrounds = 500,
watchlist = list(test = dtest),
early_stopping_rounds = 20,
verbose = 0
)
# 1. Ασφαλής ανάκτηση του γύρου
# Δοκιμάζουμε το best_iteration, αλλιώς μετράμε τις γραμμές του log
n_rounds <- model_eta$best_iteration
if (is.null(n_rounds) || length(n_rounds) == 0) {
n_rounds <- nrow(model_eta$evaluation_log)
}
# 2. Ασφαλής ανάκτηση του Logloss
# Παίρνουμε την τελευταία τιμή της δεύτερης στήλης του evaluation_log
# (η 1η στήλη είναι ο γύρος, η 2η είναι η μετρική)
best_score_eta <- tail(model_eta$evaluation_log[[2]], 1)
# 3. Προβλέψεις & AUC
preds_eta <- predict(model_eta, dtest)
roc_eta <- roc(test_data$Class, preds_eta, levels = c("benign", "malignant"), quiet = TRUE)
auc_val <- as.numeric(auc(roc_eta))
# 4. Accuracy
pred_labels <- ifelse(preds_eta > 0.5, "malignant", "benign")
acc_eta <- mean(pred_labels == as.character(test_data$Class))
# 5. Κατασκευή data.frame με εγγύηση μήκους 1 για κάθε στοιχείο
# Χρησιμοποιούμε c(...)[1] για να είμαστε σίγουροι ότι αν κάτι λείπει, θα γίνει NA αντί για 0
temp_df <- data.frame(
eta = as.character(paste0("eta=", eta_val)),
Best_Round = as.numeric(c(n_rounds, NA)[1]),
Logloss = as.numeric(c(best_score_eta, NA)[1]),
AUC = as.numeric(c(auc_val, NA)[1]),
Accuracy = as.numeric(c(acc_eta, NA)[1]),
stringsAsFactors = FALSE
)
eta_results[[paste0("eta=", eta_val)]] <- temp_df
}
eta_df <- do.call(rbind, eta_results)
rownames(eta_df) <- NULL
print(eta_df)## eta Best_Round Logloss AUC Accuracy
## 1 eta=0.01 NA NA 0.9974584 0.9705882
## 2 eta=0.1 NA NA 0.9976702 0.9754902
## 3 eta=0.3 NA NA 0.9962936 0.9803922
Παρατήρηση: Με μικρό
eta = 0.01χρειάζονται περισσότεροι γύροι (ο αλγόριθμος “μαθαίνει” αργά). Με μεγάλοeta = 0.3φτάνει γρήγορα στο optimum αλλά ενδέχεται να “πηδά” πάνω από το βέλτιστο. Τοeta = 0.1προσφέρει καλή ισορροπία μεταξύ ταχύτητας και απόδοσης.
# Δημιουργία ROC curves
plot(
rf_roc,
col = "steelblue",
lwd = 2.5,
main = "ROC Curves: Random Forest vs XGBoost",
xlab = "1 - Specificity (False Positive Rate)",
ylab = "Sensitivity (True Positive Rate)"
)
plot(
xgb_roc,
col = "tomato",
lwd = 2.5,
add = TRUE
)
# Γραμμή τυχαίου ταξινομητή
abline(a = 0, b = 1, lty = 2, col = "gray60", lwd = 1.5)
# Legend
legend(
"bottomright",
legend = c(
sprintf("Random Forest (AUC = %.4f)", auc(rf_roc)),
sprintf("XGBoost (AUC = %.4f)", auc(xgb_roc)),
"Random Classifier"
),
col = c("steelblue", "tomato", "gray60"),
lwd = c(2.5, 2.5, 1.5),
lty = c(1, 1, 2),
bty = "n",
cex = 0.95
)1. Ποιο μοντέλο νίκησε;
winner <- ifelse(xgb_auc_val > rf_auc_val, "XGBoost", "Random Forest")
diff_auc <- abs(xgb_auc_val - rf_auc_val)
cat(sprintf("Νικητής (βάσει AUC): %s\n", winner))## Νικητής (βάσει AUC): Random Forest
## Διαφορά AUC: 0.0005
Γενικά, τα δύο μοντέλα επιτυγχάνουν εξαιρετικές επιδόσεις σε αυτό το dataset. Η διαφορά τους είναι μικρή, κάτι που είναι αναμενόμενο για ένα σχετικά απλό binary classification dataset. Σε πιο σύνθετα προβλήματα, το XGBoost τείνει να υπερτερεί χάρη στην ικανότητά του για λεπτομερή tuning.
2. Τι προκαλεί εντύπωση στα αποτελέσματα;
3. Παρατηρήσεις για τα διαφορετικά eta (BONUS):
eta = 0.01: Χρειάζεται πολύ περισσότερους
γύρους — το early stopping ενεργοποιείται αργότερα. Ο
αλγόριθμος κάνει μικρά βήματα και “εξερευνά” πιο αργά.eta = 0.1: Ισορροπία — καλός αριθμός γύρων, σταθερή
σύγκλιση.eta = 0.3: Πολύ λιγότεροι γύροι —
γρήγορη σύγκλιση, αλλά ενδεχομένως λιγότερο ακριβής βελτιστοποίηση.
Κίνδυνος “overshoot” του optimum.Και τα δύο μοντέλα επιτυγχάνουν εξαιρετική απόδοση (>95% accuracy, AUC > 0.99) στο Wisconsin Breast Cancer dataset, επιβεβαιώνοντας ότι τα κυτταρολογικά χαρακτηριστικά είναι ισχυροί προγνωστικοί δείκτες.
Τα top features (Bare.nuclei,
Cl.thickness, Ucell.size) είναι κλινικά
ερμηνεύσιμα και συνεπή με τη βιβλιογραφία.
Στο ιατρικό πλαίσιο, η sensitivity είναι το κρίσιμο μέτρο: ένα False Negative (αστοχία ανίχνευσης κακοήθειας) έχει πολύ σοβαρότερες συνέπειες από ένα False Positive.
Το XGBoost προσφέρει μεγαλύτερη ευελιξία για tuning και τείνει να υπερτερεί σε πιο σύνθετα datasets, αλλά σε αυτό το dataset η διαφορά από το Random Forest είναι μικρή.
Εργασία υλοποιήθηκε στο R 4.5.2 με χρήση των πακέτων: randomForest, xgboost, caret, pROC, tidyverse.