1 Εισαγωγή & Κλινικό Πλαίσιο

Σε αυτήν την εργασία αναλαμβάνουμε τον ρόλο data scientists σε ογκολογικό κέντρο. Στόχος μας είναι η κατασκευή ενός μοντέλου decision support για τους γιατρούς, το οποίο θα προβλέπει αν ένας όγκος είναι καλοήθης (benign) ή κακοήθης (malignant) βάσει κυτταρολογικών χαρακτηριστικών από βιοψίες.

Τα κριτήρια επιτυχίας δεν είναι μόνο η ακρίβεια (accuracy), αλλά και:

  • Ερμηνευσιμότητα: Ποια χαρακτηριστικά έχουν τη μεγαλύτερη επίδραση;
  • Υψηλή Ευαισθησία (Sensitivity/Recall): Να μην χάσουμε κακοήθεις περιπτώσεις.

2 Setup — Φόρτωση Πακέτων & Δεδομένων

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" ...
cat("Κατανομή κλάσεων:")
## Κατανομή κλάσεων:
table(bc$Class)
## 
##    benign malignant 
##       444       239
cat("Ποσοστά:")
## Ποσοστά:
round(prop.table(table(bc$Class)) * 100, 1)
## 
##    benign malignant 
##        65        35

Παρατήρηση: Το dataset είναι ελαφρά ανισόρροπο (~65% benign, ~35% malignant). Αυτό είναι σημαντικό να το λαμβάνουμε υπόψη κατά την αξιολόγηση.


3 Μέρος Α — Baseline με Random Forest

3.1 TODO 1: Stratified Train/Test Split (70/30)

Χρησιμοποιούμε 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 παρατηρήσεις
cat("Test set:    ", nrow(test_data),  "παρατηρήσεις\n")
## Test set:     204 παρατηρήσεις
cat("\nΚατανομή κλάσεων στο training set:\n")
## 
## Κατανομή κλάσεων στο training set:
round(prop.table(table(train_data$Class)) * 100, 1)
## 
##    benign malignant 
##      64.9      35.1
cat("\nΚατανομή κλάσεων στο test set:\n")
## 
## Κατανομή κλάσεων στο test set:
round(prop.table(table(test_data$Class)) * 100, 1)
## 
##    benign malignant 
##      65.2      34.8

3.2 TODO 2: Εκπαίδευση Random Forest

Εκπαιδεύουμε ένα 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.

3.3 TODO 3: Αξιολόγηση στο Test Set

3.3.1 Predictions & Confusion Matrix

# Προβλέψεις κλάσης
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       
## 

3.3.2 Υπολογισμός AUC

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

3.3.3 Σύνοψη Μετρικών

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 — Μετρικές Απόδοσης ===
cat(sprintf("Accuracy:    %.4f (%.2f%%)\n", rf_accuracy,    rf_accuracy    * 100))
## Accuracy:    0.9804 (98.04%)
cat(sprintf("Sensitivity: %.4f (%.2f%%)\n", rf_sensitivity, rf_sensitivity * 100))
## Sensitivity: 0.9718 (97.18%)
cat(sprintf("Specificity: %.4f (%.2f%%)\n", rf_specificity, rf_specificity * 100))
## Specificity: 0.9850 (98.50%)
cat(sprintf("AUC:         %.4f\n",           rf_auc_val))
## AUC:         0.9982

3.4 TODO 4: Variable Importance

varImpPlot(
  rf_model,
  main = "Variable Importance — Random Forest",
  col  = "steelblue",
  pch  = 19
)

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):
head(importance_df[, c("Feature", "MeanDecreaseGini")], 3)

3.5 Απαντήσεις Μέρους Α

1. Accuracy που επιτεύχθηκε:

cat(sprintf("Accuracy: %.2f%%\n", rf_accuracy * 100))
## Accuracy: 98.04%

2. Top-3 Features:

Τα τρία πιο σημαντικά χαρακτηριστικά σύμφωνα με το Mean Decrease Gini είναι:

  1. Bare.nuclei — ένδειξη μη φυσιολογικής κυτταρικής δομής
  2. Cl.thickness (Cell thickness) — πάχος κυτταρικής στοιβάδας
  3. Ucell.size (Uniformity of Cell Size) — ομοιομορφία μεγέθους κυττάρων

Αυτά τα χαρακτηριστικά είναι συνεπή με την κλινική πρακτική: οι κακοήθεις όγκοι χαρακτηρίζονται από ανομοιομορφία κυττάρων και γυμνούς πυρήνες.

3. Είναι ~97% accuracy αρκετό σε ιατρικό context;

Όχι απαραίτητα. Σε ιατρικό context, η sensitivity (recall για malignant) έχει μεγαλύτερη σημασία από το γενικό accuracy. Ένα False Negative (κακοήθης όγκος που ταξινομείται ως καλοήθης) έχει πολύ σοβαρότερες συνέπειες από ένα False Positive. Επομένως, προτεραιότητα είναι η υψηλή sensitivity, ακόμα κι αν αυτό κοστίζει λίγο σε specificity.


4 Μέρος Β — Boosting & Tuning

4.1 TODO 5: Προετοιμασία Δεδομένων για XGBoost

Το 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
cat("Test matrix:    ", nrow(X_test),  "x", ncol(X_test),  "\n")
## Test matrix:     204 x 9

4.2 TODO 6: Εκπαίδευση XGBoost με Early Stopping

Χρησιμοποιούμε 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):
cat("Best test logloss:", round(best_score, 5), "\n")
## Best test logloss:

4.2.1 Αξιολόγηση XGBoost

# Πιθανότητες
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

4.3 TODO 7: Σύγκριση XGBoost vs Random Forest

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")

4.4 TODO 8 (BONUS): Σύγκριση Διαφορετικών Learning Rates (eta)

Δοκιμάζουμε τρία διαφορετικά 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 προσφέρει καλή ισορροπία μεταξύ ταχύτητας και απόδοσης.

4.5 TODO 9 (BONUS): ROC Plot — RF vs XGBoost

# Δημιουργία 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
)


5 Απαντήσεις Μέρους Β

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
cat(sprintf("Διαφορά AUC: %.4f\n", diff_auc))
## Διαφορά AUC: 0.0005

Γενικά, τα δύο μοντέλα επιτυγχάνουν εξαιρετικές επιδόσεις σε αυτό το dataset. Η διαφορά τους είναι μικρή, κάτι που είναι αναμενόμενο για ένα σχετικά απλό binary classification dataset. Σε πιο σύνθετα προβλήματα, το XGBoost τείνει να υπερτερεί χάρη στην ικανότητά του για λεπτομερή tuning.

2. Τι προκαλεί εντύπωση στα αποτελέσματα;

  • Η εξαιρετικά υψηλή απόδοση και των δύο μοντέλων υποδηλώνει ότι τα κυτταρολογικά χαρακτηριστικά είναι πολύ διαχωρίσιμα. Αυτό είναι συνεπές με την κλινική πρακτική: η κακοήθεια αφήνει σαφή ίχνη στη μορφολογία των κυττάρων.
  • Το Random Forest, παρά την απλότητά του (χωρίς hyperparameter tuning), ανταγωνίζεται το XGBoost, δείχνοντας ότι για structured tabular data με καλά features, ακόμα και απλά ensemble methods είναι εξαιρετικά αποτελεσματικά.

3. Παρατηρήσεις για τα διαφορετικά eta (BONUS):

  • eta = 0.01: Χρειάζεται πολύ περισσότερους γύρους — το early stopping ενεργοποιείται αργότερα. Ο αλγόριθμος κάνει μικρά βήματα και “εξερευνά” πιο αργά.
  • eta = 0.1: Ισορροπία — καλός αριθμός γύρων, σταθερή σύγκλιση.
  • eta = 0.3: Πολύ λιγότεροι γύροι — γρήγορη σύγκλιση, αλλά ενδεχομένως λιγότερο ακριβής βελτιστοποίηση. Κίνδυνος “overshoot” του optimum.

6 Συμπεράσματα

6.1 Ευρήματα

  1. Και τα δύο μοντέλα επιτυγχάνουν εξαιρετική απόδοση (>95% accuracy, AUC > 0.99) στο Wisconsin Breast Cancer dataset, επιβεβαιώνοντας ότι τα κυτταρολογικά χαρακτηριστικά είναι ισχυροί προγνωστικοί δείκτες.

  2. Τα top features (Bare.nuclei, Cl.thickness, Ucell.size) είναι κλινικά ερμηνεύσιμα και συνεπή με τη βιβλιογραφία.

  3. Στο ιατρικό πλαίσιο, η sensitivity είναι το κρίσιμο μέτρο: ένα False Negative (αστοχία ανίχνευσης κακοήθειας) έχει πολύ σοβαρότερες συνέπειες από ένα False Positive.

  4. Το XGBoost προσφέρει μεγαλύτερη ευελιξία για tuning και τείνει να υπερτερεί σε πιο σύνθετα datasets, αλλά σε αυτό το dataset η διαφορά από το Random Forest είναι μικρή.

6.2 Περιορισμοί & Μελλοντική Εργασία

  • Το dataset έχει μόλις 699 παρατηρήσεις — σε κλινικό περιβάλλον χρειαζόμαστε μεγαλύτερο validation.
  • Δεν εξετάστηκε calibration των πιθανοτήτων, που είναι σημαντικό για κλινική χρήση.
  • Θα άξιζε εξερεύνηση threshold tuning για βελτιστοποίηση της sensitivity/specificity ισορροπίας ανάλογα με τις κλινικές ανάγκες.

Εργασία υλοποιήθηκε στο R 4.5.2 με χρήση των πακέτων: randomForest, xgboost, caret, pROC, tidyverse.