1. Data Ingestion

bc_data <- read.csv("breast_cancer.csv", stringsAsFactors = FALSE)

bc_data$id <- NULL
empty_cols <- names(bc_data)[names(bc_data) == "" | grepl("^X$|^X\\.", names(bc_data))]
bc_data[empty_cols] <- NULL
bc_data <- bc_data[, colSums(is.na(bc_data)) < nrow(bc_data)]

numeric_cols <- sapply(bc_data, is.numeric)
for (col in names(bc_data)[numeric_cols]) {
  if (any(is.na(bc_data[[col]]))) {
    bc_data[[col]][is.na(bc_data[[col]])] <- median(bc_data[[col]], na.rm = TRUE)
  }
}

bc_data$diagnosis <- factor(bc_data$diagnosis, levels = c("B", "M"),
                             labels = c("Benign", "Malignant"))
bc_data <- bc_data[!is.na(bc_data$diagnosis), ]
kable(data.frame(Metric = c("Rows", "Columns"),
                  Value = c(nrow(bc_data), ncol(bc_data))),
      caption = "Dataset Dimensions")
Dataset Dimensions
Metric Value
Rows 569
Columns 31
kable(as.data.frame(table(bc_data$diagnosis)),
      col.names = c("Diagnosis", "Count"),
      caption = "Class Distribution")
Class Distribution
Diagnosis Count
Benign 357
Malignant 212
ggplot(bc_data, aes(x = diagnosis, fill = diagnosis)) +
  geom_bar() +
  geom_text(stat = "count", aes(label = after_stat(count)), vjust = -0.5, size = 4.5) +
  scale_fill_manual(values = c("Benign" = "#4C9F70", "Malignant" = "#D9534F")) +
  labs(title = "Class Distribution", x = "Diagnosis", y = "Number of Cases") +
  theme_report +
  theme(legend.position = "none")

2. Data Partitioning

set.seed(42)
train_index <- createDataPartition(bc_data$diagnosis, p = 0.80, list = FALSE)
train_data <- bc_data[train_index, ]
test_data  <- bc_data[-train_index, ]

kable(data.frame(Set = c("Training", "Test"),
                  Rows = c(nrow(train_data), nrow(test_data))),
      caption = "Train / Test Split (80/20)")
Train / Test Split (80/20)
Set Rows
Training 456
Test 113

3. Model Fitting

set.seed(42)
tuned_mtry <- tuneRF(
  x = train_data[, -which(names(train_data) == "diagnosis")],
  y = train_data$diagnosis,
  ntreeTry = 500,
  stepFactor = 1.5,
  improve = 0.01,
  trace = FALSE,
  plot = FALSE
)
best_mtry <- tuned_mtry[which.min(tuned_mtry[, "OOBError"]), "mtry"]

Selected mtry: 3

rf_model <- randomForest(
  diagnosis ~ .,
  data = train_data,
  ntree = 1000,
  mtry = best_mtry,
  importance = TRUE
)

saveRDS(rf_model, "rf_model.rds")

print(rf_model)
## 
## Call:
##  randomForest(formula = diagnosis ~ ., data = train_data, ntree = 1000,      mtry = best_mtry, importance = TRUE) 
##                Type of random forest: classification
##                      Number of trees: 1000
## No. of variables tried at each split: 3
## 
##         OOB estimate of  error rate: 4.39%
## Confusion matrix:
##           Benign Malignant class.error
## Benign       279         7  0.02447552
## Malignant     13       157  0.07647059

The trained model is saved to rf_model.rds in the working directory, and can be reloaded later without retraining:

rf_model <- readRDS("rf_model.rds")
predict(rf_model, newdata = new_patient_data)

4. Model Evaluation

rf_predictions <- predict(rf_model, newdata = test_data)

conf_matrix <- confusionMatrix(
  rf_predictions,
  test_data$diagnosis,
  positive = "Malignant"
)

saveRDS(conf_matrix, "confusion_matrix.rds")

conf_matrix
## Confusion Matrix and Statistics
## 
##            Reference
## Prediction  Benign Malignant
##   Benign        69         0
##   Malignant      2        42
##                                           
##                Accuracy : 0.9823          
##                  95% CI : (0.9375, 0.9978)
##     No Information Rate : 0.6283          
##     P-Value [Acc > NIR] : <2e-16          
##                                           
##                   Kappa : 0.9625          
##                                           
##  Mcnemar's Test P-Value : 0.4795          
##                                           
##             Sensitivity : 1.0000          
##             Specificity : 0.9718          
##          Pos Pred Value : 0.9545          
##          Neg Pred Value : 1.0000          
##              Prevalence : 0.3717          
##          Detection Rate : 0.3717          
##    Detection Prevalence : 0.3894          
##       Balanced Accuracy : 0.9859          
##                                           
##        'Positive' Class : Malignant       
## 

Confusion Matrix Heatmap

cm_table <- as.data.frame(conf_matrix$table)
colnames(cm_table) <- c("Predicted", "Actual", "Freq")

ggplot(cm_table, aes(x = Actual, y = Predicted, fill = Freq)) +
  geom_tile(color = "white", linewidth = 1) +
  geom_text(aes(label = Freq), color = "white", size = 8, fontface = "bold") +
  scale_fill_gradient(low = "#8FBFDE", high = "#1F4E79") +
  labs(title = "Confusion Matrix", x = "Actual Diagnosis", y = "Predicted Diagnosis") +
  theme_report +
  theme(legend.position = "none")

Key Performance Metrics

metrics_df <- data.frame(
  Metric = c("Accuracy", "Sensitivity (Malignant Recall)", "Specificity"),
  Value = round(c(conf_matrix$overall["Accuracy"],
                   conf_matrix$byClass["Sensitivity"],
                   conf_matrix$byClass["Specificity"]), 4)
)
kable(metrics_df, caption = "Test Set Performance")
Test Set Performance
Metric Value
Accuracy Accuracy 0.9823
Sensitivity Sensitivity (Malignant Recall) 1.0000
Specificity Specificity 0.9718
  • Accuracy: overall proportion of correct predictions across both classes.
  • Sensitivity: of all actual malignant cases, the proportion correctly flagged — the most clinically important number, since a missed malignant case is the costliest error.
  • Specificity: of all actual benign cases, the proportion correctly cleared.
ggplot(metrics_df, aes(x = Metric, y = Value, fill = Metric)) +
  geom_col(width = 0.6) +
  geom_text(aes(label = scales::percent(Value, accuracy = 0.1)), vjust = -0.5, size = 4.5) +
  scale_y_continuous(limits = c(0, 1.05), labels = scales::percent) +
  scale_fill_manual(values = c("#1F4E79", "#D9534F", "#4C9F70")) +
  labs(title = "Test Set Performance Metrics", x = NULL, y = "Score") +
  theme_report +
  theme(legend.position = "none",
        axis.text.x = element_text(angle = 15, hjust = 1))

ROC Curve

rf_probs <- predict(rf_model, newdata = test_data, type = "prob")

roc_obj <- roc(response = test_data$diagnosis,
                predictor = rf_probs[, "Malignant"],
                levels = c("Benign", "Malignant"),
                direction = "<")

auc_val <- round(auc(roc_obj), 4)

plot(roc_obj,
     main = paste0("ROC Curve (AUC = ", auc_val, ")"),
     col = "#1F4E79", lwd = 3,
     legacy.axes = TRUE)
abline(a = 0, b = 1, lty = 2, col = "gray60")

The ROC curve shows the tradeoff between sensitivity and specificity across all possible classification thresholds. An AUC (area under the curve) close to 1.0 indicates the model separates benign from malignant cases very well; 0.5 would indicate no better than random guessing.

5. Feature Importance

importance_df <- as.data.frame(round(importance(rf_model), 3))
kable(importance_df, caption = "Variable Importance Scores")
Variable Importance Scores
Benign Malignant MeanDecreaseAccuracy MeanDecreaseGini
radius_mean 12.200 9.580 14.810 9.426
texture_mean 9.540 11.339 13.903 3.591
perimeter_mean 11.886 9.362 14.389 10.630
area_mean 14.463 9.339 15.967 11.822
smoothness_mean 3.759 9.487 10.108 1.690
compactness_mean 7.622 6.804 10.185 3.818
concavity_mean 10.555 11.049 15.351 10.667
concave.points_mean 14.498 15.465 19.711 19.383
symmetry_mean 2.073 6.083 6.588 1.006
fractal_dimension_mean 6.412 2.553 6.740 1.412
radius_se 9.762 7.989 12.616 4.994
texture_se 4.495 4.218 6.182 1.309
perimeter_se 10.427 9.676 14.146 5.248
area_se 15.152 11.325 18.681 11.276
smoothness_se 2.807 0.065 2.344 1.257
compactness_se 7.110 2.090 7.350 1.823
concavity_se 6.739 5.378 8.638 2.270
concave.points_se 6.976 3.215 7.853 2.106
symmetry_se 3.506 2.038 4.160 1.089
fractal_dimension_se 3.061 -0.787 2.010 1.417
radius_worst 17.810 15.278 21.863 18.631
texture_worst 11.425 13.369 16.371 3.767
perimeter_worst 17.038 15.039 21.162 19.923
area_worst 17.822 16.286 22.461 19.553
smoothness_worst 10.016 11.760 14.205 3.221
compactness_worst 8.632 8.718 12.425 6.053
concavity_worst 11.687 13.906 17.776 9.603
concave.points_worst 16.993 17.340 22.737 20.964
symmetry_worst 7.198 9.309 11.445 2.537
fractal_dimension_worst 5.848 4.532 7.340 2.103
varImpPlot(
  rf_model,
  main = "Random Forest - Feature Importance",
  n.var = min(15, nrow(importance_df)),
  pch = 19,
  color = "steelblue"
)

6. Session Info

sessionInfo()
## R version 4.5.2 (2025-10-31)
## Platform: aarch64-apple-darwin20
## Running under: macOS Ventura 13.3
## 
## Matrix products: default
## BLAS:   /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libBLAS.dylib 
## LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1
## 
## locale:
## [1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
## 
## time zone: America/Chicago
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
## [1] pROC_1.19.0.1        knitr_1.51           caret_7.0-1         
## [4] lattice_0.22-7       ggplot2_4.0.3        randomForest_4.7-1.2
## 
## loaded via a namespace (and not attached):
##  [1] gtable_0.3.6         xfun_0.60            bslib_0.12.0        
##  [4] recipes_1.3.3        vctrs_0.7.3          tools_4.5.2         
##  [7] generics_0.1.4       stats4_4.5.2         parallel_4.5.2      
## [10] proxy_0.4-29         tibble_3.3.1         pkgconfig_2.0.3     
## [13] ModelMetrics_1.2.2.2 Matrix_1.7-4         data.table_1.18.4   
## [16] RColorBrewer_1.1-3   S7_0.2.2             lifecycle_1.0.5     
## [19] compiler_4.5.2       farver_2.1.2         stringr_1.6.0       
## [22] codetools_0.2-20     htmltools_0.5.9      class_7.3-23        
## [25] sass_0.4.10          yaml_2.3.12          prodlim_2026.03.11  
## [28] pillar_1.11.1        jquerylib_0.1.4      MASS_7.3-65         
## [31] cachem_1.1.0         gower_1.0.2          iterators_1.0.14    
## [34] rpart_4.1.24         foreach_1.5.2        nlme_3.1-168        
## [37] parallelly_1.48.0    lava_1.9.2           tidyselect_1.2.1    
## [40] digest_0.6.39        stringi_1.8.7        future_1.75.0       
## [43] dplyr_1.2.1          reshape2_1.4.5       purrr_1.2.2         
## [46] listenv_1.0.0        labeling_0.4.3       splines_4.5.2       
## [49] fastmap_1.2.0        grid_4.5.2           cli_3.6.6           
## [52] magrittr_2.0.5       survival_3.8-3       e1071_1.7-17        
## [55] future.apply_1.20.2  withr_3.0.3          scales_1.4.0        
## [58] lubridate_1.9.5      timechange_0.4.0     rmarkdown_2.31      
## [61] globals_0.19.1       nnet_7.3-20          timeDate_4052.112   
## [64] evaluate_1.0.5       hardhat_1.4.3        rlang_1.3.0         
## [67] Rcpp_1.1.2           glue_1.8.1           ipred_0.9-15        
## [70] rstudioapi_0.19.0    jsonlite_2.0.0       R6_2.6.1            
## [73] plyr_1.8.9