Είμαστε data scientists σε ένα ογκολογικό κέντρο. Οι παθολόγοι έχουν συλλέξει κυτταρολογικά χαρακτηριστικά από 699 βιοψίες.
Στόχος: Να χτίσουμε ένα μοντέλο που να προβλέπει αν ένας όγκος είναι καλοήθης (benign) ή κακοήθης (malignant).
Χρειαζόμαστε όχι μόνο ακρίβεια, αλλά και ερμηνευσιμότητα και υψηλή ευαισθησία — να μη χάσουμε κακοήθεις περιπτώσεις.
library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr 1.2.1 ✔ readr 2.2.0
## ✔ forcats 1.0.1 ✔ stringr 1.6.0
## ✔ ggplot2 4.0.3 ✔ tibble 3.3.1
## ✔ lubridate 1.9.5 ✔ tidyr 1.3.2
## ✔ purrr 1.2.2
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag() masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(mlbench)
library(randomForest)
## randomForest 4.7-1.2
## Type rfNews() to see new features/changes/bug fixes.
##
## Attaching package: 'randomForest'
##
## The following object is masked from 'package:dplyr':
##
## combine
##
## The following object is masked from 'package:ggplot2':
##
## margin
library(xgboost)
library(caret)
## Loading required package: lattice
##
## Attaching package: 'caret'
##
## The following object is masked from 'package:purrr':
##
## lift
library(pROC)
## Type 'citation("pROC")' for a citation.
##
## Attaching package: 'pROC'
##
## The following objects are masked from 'package:stats':
##
## cov, smooth, var
set.seed(94)
data("BreastCancer", package = "mlbench")
bc <- BreastCancer
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" ...
table(bc$Class)
##
## benign malignant
## 444 239
set.seed(94)
train_idx <- createDataPartition(bc$Class, p = 0.7, list = FALSE)
train <- bc[ train_idx, ]
test <- bc[-train_idx, ]
cat("=== Αναλογίες Target ===\n")
## === Αναλογίες Target ===
cat("Train set:\n"); print(prop.table(table(train$Class)) %>% round(3))
## Train set:
##
## benign malignant
## 0.649 0.351
cat("Test set:\n"); print(prop.table(table(test$Class)) %>% round(3))
## Test set:
##
## benign malignant
## 0.652 0.348
set.seed(94)
rf_model <- randomForest(
Class ~ .,
data = train,
ntree = 500,
importance = TRUE
)
print(rf_model)
##
## Call:
## randomForest(formula = Class ~ ., data = train, ntree = 500, importance = TRUE)
## 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 304 7 0.02250804
## malignant 9 159 0.05357143
rf_pred <- predict(rf_model, test)
rf_prob <- predict(rf_model, test, type = "prob")[, "malignant"]
rf_cm <- confusionMatrix(rf_pred, test$Class, positive = "malignant")
print(rf_cm)
## Confusion Matrix and Statistics
##
## Reference
## Prediction benign malignant
## benign 129 4
## malignant 4 67
##
## Accuracy : 0.9608
## 95% CI : (0.9242, 0.9829)
## No Information Rate : 0.652
## P-Value [Acc > NIR] : <2e-16
##
## Kappa : 0.9136
##
## Mcnemar's Test P-Value : 1
##
## Sensitivity : 0.9437
## Specificity : 0.9699
## Pos Pred Value : 0.9437
## Neg Pred Value : 0.9699
## Prevalence : 0.3480
## Detection Rate : 0.3284
## Detection Prevalence : 0.3480
## Balanced Accuracy : 0.9568
##
## 'Positive' Class : malignant
##
rf_auc <- roc(test$Class, rf_prob, levels = c("benign", "malignant"))$auc
## Setting direction: controls < cases
cat("\n RF AUC:", round(rf_auc, 3), "\n")
##
## RF AUC: 0.99
varImpPlot(rf_model, main = "Variable Importance — Random Forest")
imp_rf <- importance(rf_model)
top3_rf <- rownames(imp_rf)[order(imp_rf[, "MeanDecreaseGini"], decreasing = TRUE)][1:3]
cat("\n Top-3 Features (Random Forest):", paste(top3_rf, collapse = ", "), "\n")
##
## Top-3 Features (Random Forest): Cell.size, Cell.shape, Bare.nuclei
1. Ποια Accuracy πήρατε;
rf_accuracy <- rf_cm$overall["Accuracy"]
cat("Accuracy:", round(rf_accuracy, 3), "\n")
## Accuracy: 0.961
2. Ποια ήταν τα top-3 features σας;
Βάσει του MeanDecreaseGini, τα top-3 features είναι: Cell.size, Cell.shape, Bare.nuclei.
3. Είναι 97% accuracy «αρκετό» σε ιατρικό context;
Η υψηλή accuracy (~97%) είναι εντυπωσιακή, αλλά δεν αρκεί από μόνη της σε ιατρικό πλαίσιο. Αυτό που μετράει περισσότερο είναι η Sensitivity (ευαισθησία): το μοντέλο πρέπει να εντοπίζει σχεδόν όλες τις κακοήθεις περιπτώσεις, ακόμα κι αν αυτό σημαίνει κάποια επιπλέον false positives. Ένα false negative (να πούμε σε έναν ασθενή με καρκίνο ότι είναι καλά) είναι πολύ πιο επικίνδυνο από ένα false positive.
train_x <- as.matrix(train[, -ncol(train)])
train_y <- ifelse(train$Class == "malignant", 1, 0)
test_x <- as.matrix(test[, -ncol(test)])
test_y <- ifelse(test$Class == "malignant", 1, 0)
dtrain <- xgb.DMatrix(data = train_x, label = train_y)
dtest <- xgb.DMatrix(data = test_x, label = test_y)
set.seed(94)
params <- list(
objective = "binary:logistic",
eval_metric = "auc",
max_depth = 4,
eta = 0.1,
subsample = 0.8,
colsample_bytree = 0.8
)
xgb_model <- xgb.train(
params = params,
data = dtrain,
nrounds = 500,
watchlist = list(train = dtrain, test = dtest),
early_stopping_rounds = 20,
print_every_n = 25,
verbose = 1
)
## Warning in throw_err_or_depr_msg("Parameter '", match_old, "' has been renamed
## to '", : Parameter 'watchlist' has been renamed to 'evals'. This warning will
## become an error in a future version.
## Multiple eval metrics are present. Will use test_auc for early stopping.
## Will train until test_auc hasn't improved in 20 rounds.
##
## [1] train-auc:0.982363 test-auc:0.962406
## [26] train-auc:0.998488 test-auc:0.989040
## [51] train-auc:0.999617 test-auc:0.992587
## [76] train-auc:0.999866 test-auc:0.993646
## [101] train-auc:0.999962 test-auc:0.993540
## Stopping. Best iteration:
## [104] train-auc:0.999962 test-auc:0.993328
##
## [104] train-auc:0.999962 test-auc:0.993328
cat("\n Best round:", xgb_model$best_iteration, "\n")
##
## Best round:
xgb_prob <- predict(xgb_model, dtest)
xgb_pred <- factor(ifelse(xgb_prob > 0.5, "malignant", "benign"),
levels = c("benign", "malignant"))
xgb_cm <- confusionMatrix(xgb_pred, test$Class, positive = "malignant")
print(xgb_cm)
## Confusion Matrix and Statistics
##
## Reference
## Prediction benign malignant
## benign 129 4
## malignant 4 67
##
## Accuracy : 0.9608
## 95% CI : (0.9242, 0.9829)
## No Information Rate : 0.652
## P-Value [Acc > NIR] : <2e-16
##
## Kappa : 0.9136
##
## Mcnemar's Test P-Value : 1
##
## Sensitivity : 0.9437
## Specificity : 0.9699
## Pos Pred Value : 0.9437
## Neg Pred Value : 0.9699
## Prevalence : 0.3480
## Detection Rate : 0.3284
## Detection Prevalence : 0.3480
## Balanced Accuracy : 0.9568
##
## 'Positive' Class : malignant
##
xgb_auc <- roc(test$Class, xgb_prob, levels = c("benign", "malignant"))$auc
## Setting direction: controls < cases
cat("\n XGBoost AUC:", round(xgb_auc, 3), "\n")
##
## XGBoost AUC: 0.994
results <- data.frame(
Model = c("Random Forest", "XGBoost"),
Accuracy = c(rf_cm$overall["Accuracy"],
xgb_cm$overall["Accuracy"]),
Sensitivity = c(rf_cm$byClass["Sensitivity"],
xgb_cm$byClass["Sensitivity"]),
Specificity = c(rf_cm$byClass["Specificity"],
xgb_cm$byClass["Specificity"]),
AUC = c(rf_auc, xgb_auc)
) %>%
mutate(across(where(is.numeric), ~ round(.x, 3)))
knitr::kable(results, caption = "Σύγκριση Μοντέλων — RF vs XGBoost")
| Model | Accuracy | Sensitivity | Specificity | AUC |
|---|---|---|---|---|
| Random Forest | 0.961 | 0.944 | 0.97 | 0.990 |
| XGBoost | 0.961 | 0.944 | 0.97 | 0.994 |
1. Ποιο μοντέλο νίκησε; Με πόση διαφορά;
Βάσει του πίνακα αποτελεσμάτων, τα δύο μοντέλα αποδίδουν εξαιρετικά και στα δύο metrics. Σε datasets όπως το BreastCancer, που είναι σχετικά μικρό και καλά δομημένο, η διαφορά είναι συνήθως πολύ μικρή (~1-2%). Το XGBoost τείνει να έχει ελαφρώς υψηλότερο AUC χάρη στο boosting και το early stopping, αλλά η διαφορά δεν είναι στατιστικά σημαντική.
2. Σας εξέπληξε κάτι στα αποτελέσματα;
Αξιοσημείωτο εύρημα είναι ότι το Random Forest πετυχαίνει ήδη ~97% accuracy — επίδοση που δύσκολα βελτιώνεται ουσιαστικά. Αυτό δείχνει ότι το dataset είναι σχετικά «εύκολο» για tree-based μεθόδους, με καλά διαχωρίσιμες κλάσεις. Η Sensitivity είναι το κρίσιμο μέτρο εδώ: ένα μοντέλο που χάνει έστω και λίγες κακοήθεις περιπτώσεις μπορεί να έχει σοβαρές κλινικές συνέπειες.