David Pershall
Project Portfolio
Diagnostics: Breast Cancer
Introduction
This data set is provided by the UCI Machine Learning repository at the following web address.
https://archive.ics.uci.edu/ml/datasets/Breast+Cancer+Wisconsin+(Diagnostic)
The data set description gives us some helpful information; the id number of the sample, the diagnosis, and 30 features.
The features are computed from a digitized image of a fine needle aspirate of a breast mass. They describe characteristics of the cell nuclei present in the image. There are ten real-valued features for each cell nucleus.
They are:
- radius (mean of distances from center to points on the perimeter)
- texture (standard deviation of gray-scale values)
- perimeter
- area
- smoothness (local variation in radius lengths)
- compactness (perimeter^2 / area - 1.0)
- concavity (severity of concave portions of the contour)
- concave points (number of concave portions of the contour)
- symmetry
- fractal dimension (“coastline approximation” - 1)
The mean, standard error, and “worst” or largest (mean of the three largest values) of these features were computed for each image, resulting in a total of 30 measured features.
The goal of this project is to analyze the features and compose an algorithm for classifying the samples as “M” for malignant or “B” for benign with as high a rate of accuracy as possible. Let’s see if we can make it easier for doctors to determine if the fine needle aspirate of a mass is indeed malignant.
Libraries
These are the libraries used in my project.
library(matrixStats)
library(tidyverse)
library(caret)
library(gam)
library(rattle)
library(ggrepel)
library(pROC)# Tuning grids — defined once and reused identically in both the
# test-split run (Models) and the final validation run (Validation),
# so results are comparable and reproducible.
knn_grid <- data.frame(k = seq(1, 21, 2))
nn_grid <- expand.grid(.size = seq(5, 10, 1), .decay = seq(0.10, 0.20, 0.01))
rf_grid <- data.frame(mtry = c(1, 2, 3))
rpart_grid <- data.frame(cp = seq(.022, .023, .0001))Data Pull
The following pulls the data from the machine learning database at UCI. The feature names are not included in the data file, so we have to add them manually.
temp_dat <- read.csv(url(
"https://archive.ics.uci.edu/ml/machine-learning-databases/breast-cancer-wisconsin/wdbc.data"),
header = FALSE,
col.names = c("ID", "diagnosis", "radius_mean",
"texture_mean", "perimeter_mean",
"area_mean", "smoothness_mean",
"compactness_mean", "concavity_mean",
"concave_pts_mean", "symmetry_mean",
"fractal_dim_mean", "radius_se",
"texture_se", "perimeter_se", "area_se",
"smoothness_se", "compactness_se",
"concavity_se", "concave_pts_se",
"symmetry_se", "fractal_dim_se",
"radius_worst", "texture_worst",
"perimeter_worst", "area_worst",
"smoothness_worst", "compactness_worst",
"concavity_worst", "concave_pts_worst",
"symmetry_worst", "fractal_dim_worst"))
# make the diagnosis a factor B or M
temp_dat <- temp_dat %>% mutate(diagnosis = as.factor(diagnosis))
# take a quick look at the data
glimpse(temp_dat)## Rows: 569
## Columns: 32
## $ ID <int> 842302, 842517, 84300903, 84348301, 84358402, 843786…
## $ diagnosis <fct> M, M, M, M, M, M, M, M, M, M, M, M, M, M, M, M, M, M…
## $ radius_mean <dbl> 17.990, 20.570, 19.690, 11.420, 20.290, 12.450, 18.2…
## $ texture_mean <dbl> 10.38, 17.77, 21.25, 20.38, 14.34, 15.70, 19.98, 20.…
## $ perimeter_mean <dbl> 122.80, 132.90, 130.00, 77.58, 135.10, 82.57, 119.60…
## $ area_mean <dbl> 1001.0, 1326.0, 1203.0, 386.1, 1297.0, 477.1, 1040.0…
## $ smoothness_mean <dbl> 0.11840, 0.08474, 0.10960, 0.14250, 0.10030, 0.12780…
## $ compactness_mean <dbl> 0.27760, 0.07864, 0.15990, 0.28390, 0.13280, 0.17000…
## $ concavity_mean <dbl> 0.30010, 0.08690, 0.19740, 0.24140, 0.19800, 0.15780…
## $ concave_pts_mean <dbl> 0.14710, 0.07017, 0.12790, 0.10520, 0.10430, 0.08089…
## $ symmetry_mean <dbl> 0.2419, 0.1812, 0.2069, 0.2597, 0.1809, 0.2087, 0.17…
## $ fractal_dim_mean <dbl> 0.07871, 0.05667, 0.05999, 0.09744, 0.05883, 0.07613…
## $ radius_se <dbl> 1.0950, 0.5435, 0.7456, 0.4956, 0.7572, 0.3345, 0.44…
## $ texture_se <dbl> 0.9053, 0.7339, 0.7869, 1.1560, 0.7813, 0.8902, 0.77…
## $ perimeter_se <dbl> 8.589, 3.398, 4.585, 3.445, 5.438, 2.217, 3.180, 3.8…
## $ area_se <dbl> 153.40, 74.08, 94.03, 27.23, 94.44, 27.19, 53.91, 50…
## $ smoothness_se <dbl> 0.006399, 0.005225, 0.006150, 0.009110, 0.011490, 0.…
## $ compactness_se <dbl> 0.049040, 0.013080, 0.040060, 0.074580, 0.024610, 0.…
## $ concavity_se <dbl> 0.05373, 0.01860, 0.03832, 0.05661, 0.05688, 0.03672…
## $ concave_pts_se <dbl> 0.015870, 0.013400, 0.020580, 0.018670, 0.018850, 0.…
## $ symmetry_se <dbl> 0.03003, 0.01389, 0.02250, 0.05963, 0.01756, 0.02165…
## $ fractal_dim_se <dbl> 0.006193, 0.003532, 0.004571, 0.009208, 0.005115, 0.…
## $ radius_worst <dbl> 25.38, 24.99, 23.57, 14.91, 22.54, 15.47, 22.88, 17.…
## $ texture_worst <dbl> 17.33, 23.41, 25.53, 26.50, 16.67, 23.75, 27.66, 28.…
## $ perimeter_worst <dbl> 184.60, 158.80, 152.50, 98.87, 152.20, 103.40, 153.2…
## $ area_worst <dbl> 2019.0, 1956.0, 1709.0, 567.7, 1575.0, 741.6, 1606.0…
## $ smoothness_worst <dbl> 0.1622, 0.1238, 0.1444, 0.2098, 0.1374, 0.1791, 0.14…
## $ compactness_worst <dbl> 0.6656, 0.1866, 0.4245, 0.8663, 0.2050, 0.5249, 0.25…
## $ concavity_worst <dbl> 0.71190, 0.24160, 0.45040, 0.68690, 0.40000, 0.53550…
## $ concave_pts_worst <dbl> 0.26540, 0.18600, 0.24300, 0.25750, 0.16250, 0.17410…
## $ symmetry_worst <dbl> 0.4601, 0.2750, 0.3613, 0.6638, 0.2364, 0.3985, 0.30…
## $ fractal_dim_worst <dbl> 0.11890, 0.08902, 0.08758, 0.17300, 0.07678, 0.12440…
OK, it looks like the data pull went according to plan.
Exploration
Let’s start off with a relatively simple scatter plot comparing the perimeter mean and the perimeter worst features.
temp_dat %>%
group_by(diagnosis) %>%
ggplot(aes(radius_mean, radius_worst, color = diagnosis)) +
geom_point(alpha = .5)This gives us some helpful information. First, it tells us that the average radius for benign tumors is lower than the average radius for malignant tumors. Secondly, it shows us that there is some overlap where we could potentially misdiagnose the tumors if these were the only features measured.
Let’s examine some of the other features as well. The following code makes a box plot of the texture mean and texture worst features.
temp_dat %>% group_by(diagnosis) %>%
ggplot(aes(texture_mean, texture_worst, fill = diagnosis)) +
geom_boxplot()This shows us that, on average, benign tumors have lower values of both texture mean and texture worst measurements. It also shows us that some samples would cause an error in classification if we did not know their diagnosis in advance. So, we need to dig a little deeper and see if we can get a better delineation of classes (benign or malignant) through principal component analysis.
Principal Component Analysis
The first step will be to coerce the data into a list of two variables. The first variable will be termed x and it will hold all the features without the ID or diagnosis, and the second variable, y, will hold only the diagnosis.
dat <- list(x = as.matrix(temp_dat[3:32]), y=as.factor(temp_dat$diagnosis))
# Now I will check the class of the two variables and the dimensions of the
# x feature matrix to ensure all the features I wanted to keep have been
# maintained
class(dat$x)## [1] "matrix" "array"
## [1] "factor"
## [1] 569
## [1] 30
The data is now stored in “dat” and the “dat$x” variable has maintained all 569 samples along with the 30 feature observations.
Time to run some calculations.
First up, I would like to know what proportion of the samples have been classified as malignant, and what proportion of samples have been classified as benign?
## [1] 0.3725835
## [1] 0.6274165
I wonder what feature has the highest mean, and what feature has the lowest standard deviation?
## area_worst
## 24
## fractal_dim_se
## 20
The area worst feature has the highest average measurement, which makes sense. Column 20 corresponds with the standard error of the fractal dimension (“coastline approximation” - 1), and it has the lowest standard deviation.
Next up, I want to center and then scale the features.
x_centered <- sweep(dat$x, 2, colMeans(dat$x))
x_scaled <- sweep(x_centered, 2, colSds(dat$x), FUN = "/")
sd(x_scaled[,1])## [1] 1
## [1] -0.2148925
Let’s use the x_scaled data to calculate the distance between all samples. Then we can compute the average distance between the first sample, which is malignant, and the other malignant samples; followed by the average distance between the first malignant sample and other benign samples.
# average distance between all samples
d_samples <- dist(x_scaled)
# first malignant to other malignant samples - average distance
dist_MtoM <- as.matrix(d_samples)[1, dat$y == "M"]
mean(dist_MtoM[2:length(dist_MtoM)])## [1] 9.50695
# first malignant to other benign samples - average distance
dist_MtoB <- as.matrix(d_samples)[1, dat$y == "B"]
mean(dist_MtoB)## [1] 13.14004
Now, I will make a heat-map of the relationship between features using the scaled matrix.
d_features <- dist(t(x_scaled))
heatmap(as.matrix(d_features),col = RColorBrewer::brewer.pal(11, "Spectral"))We can clearly see that there are high correlations between some of our features.
Let’s cut the features into 5 groups and examine the hierarchy of the features.
## $`1`
## [1] "radius_mean" "perimeter_mean" "area_mean"
## [4] "concavity_mean" "concave_pts_mean" "radius_se"
## [7] "perimeter_se" "area_se" "radius_worst"
## [10] "perimeter_worst" "area_worst" "concave_pts_worst"
##
## $`2`
## [1] "texture_mean" "texture_worst"
##
## $`3`
## [1] "smoothness_mean" "compactness_mean" "symmetry_mean"
## [4] "fractal_dim_mean" "smoothness_worst" "compactness_worst"
## [7] "concavity_worst" "symmetry_worst" "fractal_dim_worst"
##
## $`4`
## [1] "texture_se" "smoothness_se" "symmetry_se"
##
## $`5`
## [1] "compactness_se" "concavity_se" "concave_pts_se" "fractal_dim_se"
We can use these groups to attempt a delineation between benign and malignant tumors. The following code selects one feature from the 1st group and one feature from the 2nd group and makes a scatter plot.
OK, we are getting closer. However, there is still quite a bit of overlap between benign and malignant tumors.
The PCA below is exploratory — it’s used to understand feature structure and visualize separability. A PCA-based model is fit and compared against the full-feature models later in the Models section.
pca <- prcomp(x_scaled)
pca_sum <- summary(pca)
# show the summary of the principal component analysis
pca_sum$importance ## PC1 PC2 PC3 PC4 PC5 PC6
## Standard deviation 3.644394 2.385656 1.678675 1.407352 1.284029 1.098798
## Proportion of Variance 0.442720 0.189710 0.093930 0.066020 0.054960 0.040250
## Cumulative Proportion 0.442720 0.632430 0.726360 0.792390 0.847340 0.887590
## PC7 PC8 PC9 PC10 PC11
## Standard deviation 0.8217178 0.6903746 0.6456739 0.5921938 0.5421399
## Proportion of Variance 0.0225100 0.0158900 0.0139000 0.0116900 0.0098000
## Cumulative Proportion 0.9101000 0.9259800 0.9398800 0.9515700 0.9613700
## PC12 PC13 PC14 PC15 PC16
## Standard deviation 0.5110395 0.4912815 0.3962445 0.3068142 0.2826001
## Proportion of Variance 0.0087100 0.0080500 0.0052300 0.0031400 0.0026600
## Cumulative Proportion 0.9700700 0.9781200 0.9833500 0.9864900 0.9891500
## PC17 PC18 PC19 PC20 PC21
## Standard deviation 0.2437192 0.2293878 0.2224356 0.1765203 0.1731268
## Proportion of Variance 0.0019800 0.0017500 0.0016500 0.0010400 0.0010000
## Cumulative Proportion 0.9911300 0.9928800 0.9945300 0.9955700 0.9965700
## PC22 PC23 PC24 PC25 PC26
## Standard deviation 0.1656484 0.1560155 0.1343689 0.1244238 0.0904303
## Proportion of Variance 0.0009100 0.0008100 0.0006000 0.0005200 0.0002700
## Cumulative Proportion 0.9974900 0.9983000 0.9989000 0.9994200 0.9996900
## PC27 PC28 PC29 PC30
## Standard deviation 0.08306903 0.0398665 0.02736427 0.01153451
## Proportion of Variance 0.00023000 0.0000500 0.00002000 0.00000000
## Cumulative Proportion 0.99992000 0.9999700 1.00000000 1.00000000
# plot the proportion of variance explained and the principal component index
var_explained <- cumsum(pca$sdev^2/sum(pca$sdev^2))
plot(var_explained, ylab = "Proportion of Variation Explained", xlab = "PC Index")Here we see that 90% of the variance is explained by the first 7 principal components alone. Let’s make a quick scatter plot of the first two principal components with color representing the tumor type.
data.frame(pca$x[,1:2], type = dat$y) %>%
ggplot(aes(PC1, PC2, color = type)) +
geom_point(alpha = 0.5)Here we see that benign tumors tend to have higher values of PC1; we also observe that benign and malignant tumors have a similar spread of values for PC2. We also achieve much better delineation between classes than the previous analysis achieved. However, there are still some outliers present.
Let’s make box-plots of the first 7 principal components grouped by tumor type, because as we already discovered, the first 7 PC’s explain 90% of the variance.
data.frame(type = dat$y, pca$x[,1:7]) %>%
gather(key = "PC", value = "value", -type) %>%
ggplot(aes(PC, value, fill = type)) +
geom_boxplot()Now that we have better idea of the predictive challenges ahead, let’s move on to splitting the data before we scale it for modeling, to avoid any leakage from the test/validation sets into our training statistics.
The split
Time to split the data. The goal is to be able to predict as accurately as possible whether a new sample taken from a fine needle aspirate of a tumor is benign or malignant. Therefore, I want to split the data twice so that we can train and test algorithms on a subset before I run the final validation on a hold out set. Each split will be 20/80 (20% for testing and 80% for training).
# Split on the RAW (unscaled) data first, so no test/validation information
# ever touches the scaling statistics used for training.
set.seed(1654, sample.kind = "Rounding")
test_index <- createDataPartition(y = temp_dat$diagnosis, times = 1, p = 0.2, list = FALSE)
Val_raw <- temp_dat[test_index, ]
training_raw <- temp_dat[-test_index, ]
set.seed(4123, sample.kind = "Rounding")
test_index2 <- createDataPartition(y = training_raw$diagnosis, times = 1, p = 0.2, list = FALSE)
test_raw <- training_raw[test_index2, ]
train_raw <- training_raw[-test_index2, ]
# Helper: apply a given center/scale (computed elsewhere) to a feature matrix
scale_with_stats <- function(x, center, scale) {
sweep(sweep(as.matrix(x), 2, center), 2, scale, FUN = "/")
}
# --- Small split (train/test) — statistics from train_raw ONLY ---
train_means <- colMeans(as.matrix(train_raw[3:32]))
train_sds <- colSds(as.matrix(train_raw[3:32]))
train <- as.data.frame(scale_with_stats(train_raw[3:32], train_means, train_sds)) %>%
cbind(y = train_raw$diagnosis)
test <- as.data.frame(scale_with_stats(test_raw[3:32], train_means, train_sds)) %>%
cbind(y = test_raw$diagnosis)
# --- Large split (training/validation) — statistics from training_raw ONLY ---
training_means <- colMeans(as.matrix(training_raw[3:32]))
training_sds <- colSds(as.matrix(training_raw[3:32]))
training <- as.data.frame(scale_with_stats(training_raw[3:32], training_means, training_sds)) %>%
cbind(y = training_raw$diagnosis)
Val <- as.data.frame(scale_with_stats(Val_raw[3:32], training_means, training_sds)) %>%
cbind(y = Val_raw$diagnosis)
rm(test_index, test_index2, h, d_features, d_samples, dist_MtoM, dist_MtoB, groups)Let’s check the train and test sets for the prevalence of benign tumors.
## [1] 0.6280992
## [1] 0.6263736
Perfect, the prevalence is relatively the same among our train and test sets.
Models
I am now ready to move into generative models. I will compose several classification models, and then use an ensemble approach for the predictions on the test set. This method should improve the accuracy and minimize the confidence interval.
Logistic Regression
train_glm <- train(y~.,data = train, method = "glm")
glm_preds <- predict(train_glm, test)
glm_acc <- mean(glm_preds == test$y)
Accuracy_Results <- tibble(Method = "GLM", Accuracy = glm_acc)
Accuracy_Results %>% knitr::kable(align='l')| Method | Accuracy |
|---|---|
| GLM | 0.9230769 |
Linear Discriminatory Analysis
train_lda <- train(y~., data = train, method = "lda", preProcess = "center")
lda_preds <- predict(train_lda, test)
lda_acc <- mean(lda_preds == test$y)
Accuracy_Results <- bind_rows(Accuracy_Results,
tibble(Method = "LDA", Accuracy = lda_acc))
Accuracy_Results %>% knitr::kable(align='l')| Method | Accuracy |
|---|---|
| GLM | 0.9230769 |
| LDA | 0.9560440 |
Let’s examine the LDA model by making a plot of the predictors and their importance in the model for classifying malignant tumors.
t(train_lda$finalModel$means) %>%
data.frame() %>%
mutate(predictor_name = rownames(.)) %>%
ggplot(aes(predictor_name, M, label=predictor_name)) +
geom_point()+
coord_flip()Interesting, concave_pts_worst was the most important variable for classifying malignant tumors in the LDA model.
Quadratic Discriminant Analysis
train_qda <- train(y~., train, method = "qda", preProcess = "center")
qda_preds <- predict(train_qda, test)
qda_acc <- mean(qda_preds == test$y)
Accuracy_Results <- bind_rows(Accuracy_Results,
tibble(Method = "QDA", Accuracy = qda_acc))
Accuracy_Results %>% knitr::kable(align='l')| Method | Accuracy |
|---|---|
| GLM | 0.9230769 |
| LDA | 0.9560440 |
| QDA | 0.9670330 |
Let’s again make a plot of the predictors and their importance to the QDA model in classifying malignant tumors.
t(train_qda$finalModel$means) %>% data.frame() %>%
mutate(predictor_name = rownames(.)) %>%
ggplot(aes(predictor_name, M, label=predictor_name)) +
geom_point()+
coord_flip()So, we see the level of feature importance for the QDA model is the same as the LDA model.
GamLOESS
set.seed(5, sample.kind = "Rounding")
train_loess <- train(y~., data = train, method = "gamLoess")
loess_preds <- predict(train_loess, test)
loess_acc <- mean(loess_preds == test$y)
Accuracy_Results <- bind_rows(Accuracy_Results,
tibble(Method = "gamLoess", Accuracy = loess_acc))
Accuracy_Results %>% knitr::kable(align='l')| Method | Accuracy |
|---|---|
| GLM | 0.9230769 |
| LDA | 0.9560440 |
| QDA | 0.9670330 |
| gamLoess | 0.9340659 |
K nearest neighbors
set.seed(7, sample.kind="Rounding")
train_knn <- train(y~.,
data = train,
method = "knn",
tuneGrid = knn_grid)
# show the best tune
plot(train_knn)knn_preds <- predict(train_knn, test)
knn_acc <- mean(knn_preds == test$y)
Accuracy_Results <- bind_rows(Accuracy_Results,
tibble(Method = "KNN", Accuracy = knn_acc))
Accuracy_Results %>% knitr::kable(align='l')| Method | Accuracy |
|---|---|
| GLM | 0.9230769 |
| LDA | 0.9560440 |
| QDA | 0.9670330 |
| gamLoess | 0.9340659 |
| KNN | 0.9450549 |
Random Forest
set.seed(9, sample.kind="Rounding")
train_rf <- train(y~.,
data = train,
method = "rf",
tuneGrid = rf_grid,
trControl = trainControl(method = "cv",
number = 10),
importance = TRUE)We can examine the tuning with a plot.
Now that we know we have a reasonably tuned random forest model, let’s examine the importance of the features to the classification process.
OK, we find that many of the most important features are the “worst” features. We also find that the importance of features for the Random Forest model is different than what we found with the LDA and QDA models.
Time to run the predictions and report the accuracy.
rf_preds <- predict(train_rf, test)
rf_acc <- mean(rf_preds == test$y)
Accuracy_Results <- bind_rows(Accuracy_Results,
tibble(Method = "RF", Accuracy = rf_acc))
Accuracy_Results %>% knitr::kable(align='l')| Method | Accuracy |
|---|---|
| GLM | 0.9230769 |
| LDA | 0.9560440 |
| QDA | 0.9670330 |
| gamLoess | 0.9340659 |
| KNN | 0.9450549 |
| RF | 0.9340659 |
Neural Network with repeated Cross Validation
set.seed(2976, sample.kind = "Rounding")
# I will use cross validation in the train set in order to find the optimal
# hidden layers and decay.
tc <- trainControl(method = "repeatedcv", number = 10, repeats=3)
train_nn <- train(y~.,
data=train,
method='nnet',
linout=FALSE,
trace = FALSE,
trControl = tc,
tuneGrid = nn_grid) Plot the different models to see the effect on accuracy
Show the best Tune
## size decay
## 46 9 0.11
nn_preds <- predict(train_nn, test)
nn_acc <- mean(nn_preds == test$y)
Accuracy_Results <- bind_rows(Accuracy_Results,
tibble(Method = "NNet", Accuracy = nn_acc))
Accuracy_Results %>% knitr::kable(align='l')| Method | Accuracy |
|---|---|
| GLM | 0.9230769 |
| LDA | 0.9560440 |
| QDA | 0.9670330 |
| gamLoess | 0.9340659 |
| KNN | 0.9450549 |
| RF | 0.9340659 |
| NNet | 0.9560440 |
Recursive Partitioning - Rpart
set.seed(10, sample.kind = "Rounding")
train_rpart <- train(y ~ .,
data = train,
method = "rpart",
trControl = trainControl(method = "cv", number = 10),
tuneGrid = rpart_grid)
# Show the best tune
train_rpart$bestTune## cp
## 11 0.023
# plot the resulting decision tree from the rpart model.
fancyRpartPlot(train_rpart$finalModel, yesno = 2)rpart_preds <- predict(train_rpart, test)
rpart_acc <- mean(rpart_preds == test$y)
Accuracy_Results <- bind_rows(Accuracy_Results,
tibble(Method = "RPart", Accuracy = rpart_acc))
Accuracy_Results %>% knitr::kable(align='l')| Method | Accuracy |
|---|---|
| GLM | 0.9230769 |
| LDA | 0.9560440 |
| QDA | 0.9670330 |
| gamLoess | 0.9340659 |
| KNN | 0.9450549 |
| RF | 0.9340659 |
| NNet | 0.9560440 |
| RPart | 0.9010989 |
All of the models we have run so far have an accuracy over 90%. We know from examining the feature importance in the models that they are weighting the features differently for their use in classifying the tumor samples. Therefore, we should expect an increase in sensitivity by creating an ensemble.
First, let’s examine the most accurate model so far with a confusion matrix.
## Confusion Matrix and Statistics
##
## Reference
## Prediction B M
## B 56 2
## M 1 32
##
## Accuracy : 0.967
## 95% CI : (0.9067, 0.9931)
## No Information Rate : 0.6264
## P-Value [Acc > NIR] : 8.87e-15
##
## Kappa : 0.9291
##
## Mcnemar's Test P-Value : 1
##
## Sensitivity : 0.9825
## Specificity : 0.9412
## Pos Pred Value : 0.9655
## Neg Pred Value : 0.9697
## Prevalence : 0.6264
## Detection Rate : 0.6154
## Detection Prevalence : 0.6374
## Balanced Accuracy : 0.9618
##
## 'Positive' Class : B
##
We will compare these results to a confusion matrix of an ensemble approach. But first, let’s also check whether reducing to a handful of principal components costs us much accuracy compared to using all 30 scaled features.
PCA-based comparison model
# Fit PCA on TRAIN only, then project test onto the same rotation.
# This keeps the comparison honest — no leakage from test into the PCA basis.
train_x <- train %>% dplyr::select(-y)
test_x <- test %>% dplyr::select(-y)
pca_train <- prcomp(train_x)
n_pcs <- which(cumsum(pca_train$sdev^2 / sum(pca_train$sdev^2)) >= 0.90)[1]
train_pcs <- as.data.frame(pca_train$x[, 1:n_pcs]) %>% cbind(y = train$y)
test_pcs <- as.data.frame(predict(pca_train, test_x)[, 1:n_pcs]) %>% cbind(y = test$y)
train_glm_pca <- train(y ~ ., data = train_pcs, method = "glm")
glm_pca_preds <- predict(train_glm_pca, test_pcs)
glm_pca_acc <- mean(glm_pca_preds == test$y)
Accuracy_Results <- bind_rows(Accuracy_Results,
tibble(Method = paste0("GLM (", n_pcs, " PCs)"),
Accuracy = glm_pca_acc))
Accuracy_Results %>% knitr::kable(align='l')| Method | Accuracy |
|---|---|
| GLM | 0.9230769 |
| LDA | 0.9560440 |
| QDA | 0.9670330 |
| gamLoess | 0.9340659 |
| KNN | 0.9450549 |
| RF | 0.9340659 |
| NNet | 0.9560440 |
| RPart | 0.9010989 |
| GLM (7 PCs) | 0.9560440 |
Ensemble Method
I will create a new data frame that holds all of the benign predictions from the previous generative models.
ensemble <- cbind(glm = glm_preds == "B", lda = lda_preds == "B",
qda = qda_preds == "B", loess = loess_preds == "B",
rf = rf_preds == "B", nn = nn_preds == "B",
rp = rpart_preds == "B", knn = knn_preds == "B")I will say that if more than half of the algorithms predicted benign then the ensemble will predict benign. This also means that if less than half predict benign then the ensemble will predict malignant.
As an alternative to a simple majority vote, I will also fit a stacked ensemble: a logistic regression meta-learner trained on the base models’ predictions, so it can learn how much to trust each one rather than treating them all equally.
stack_train_df <- as.data.frame(ensemble) %>%
mutate(across(everything(), as.numeric)) %>%
cbind(y = test$y)
train_stack <- train(y ~ ., data = stack_train_df, method = "glm")
# Inspect learned weights per base model
summary(train_stack$finalModel)$coefficients %>% knitr::kable(align = 'l')| Estimate | Std. Error | z value | Pr(>|z|) | |
|---|---|---|---|---|
| (Intercept) | 72.491422 | 77684.08 | 0.0009332 | 0.9992554 |
| glm | 50.765202 | 278088.19 | 0.0001826 | 0.9998543 |
| lda | -2.228575 | 244889.71 | -0.0000091 | 0.9999927 |
| qda | -49.998411 | 99891.39 | -0.0005005 | 0.9996006 |
| loess | -49.128220 | 76513.39 | -0.0006421 | 0.9994877 |
| rf | -49.190333 | 77684.85 | -0.0006332 | 0.9994948 |
| nn | -1.358384 | 237841.45 | -0.0000057 | 0.9999954 |
| rp | 49.888000 | 116828.86 | 0.0004270 | 0.9996593 |
| knn | -47.087710 | 174544.16 | -0.0002698 | 0.9997848 |
Let’s check the accuracy of the ensemble method against the test set and present the findings by attaching it to our running table.
ensemble_acc <- mean(ensemble_preds == test$y)
Accuracy_Results <- bind_rows(Accuracy_Results,
tibble(Method = "Ensemble",
Accuracy = ensemble_acc))
Accuracy_Results %>% knitr::kable(align='l')| Method | Accuracy |
|---|---|
| GLM | 0.9230769 |
| LDA | 0.9560440 |
| QDA | 0.9670330 |
| gamLoess | 0.9340659 |
| KNN | 0.9450549 |
| RF | 0.9340659 |
| NNet | 0.9560440 |
| RPart | 0.9010989 |
| GLM (7 PCs) | 0.9560440 |
| Ensemble | 0.9670330 |
Now, let’s examine a confusion matrix to see if we have better results than the QDA model had on it’s own.
## Confusion Matrix and Statistics
##
## Reference
## Prediction B M
## B 57 3
## M 0 31
##
## Accuracy : 0.967
## 95% CI : (0.9067, 0.9931)
## No Information Rate : 0.6264
## P-Value [Acc > NIR] : 8.87e-15
##
## Kappa : 0.9283
##
## Mcnemar's Test P-Value : 0.2482
##
## Sensitivity : 1.0000
## Specificity : 0.9118
## Pos Pred Value : 0.9500
## Neg Pred Value : 1.0000
## Prevalence : 0.6264
## Detection Rate : 0.6264
## Detection Prevalence : 0.6593
## Balanced Accuracy : 0.9559
##
## 'Positive' Class : B
##
OK! The ensemble method has improved the sensitivity. We also see that the negative prediction value is 1. This means that every time (100% of the time) the ensemble method predicted malignancy, the tumor was indeed malignant.
Let’s repeat the above steps with the data from the first split, and run the final validation on the hold out set.
Validation
set.seed(1, sample.kind = "Rounding")
train_glm <- train(y~.,data = training, method = "glm")
set.seed(2, sample.kind = "Rounding")
train_lda <- train(y~., data = training, method = "lda")
set.seed(3, sample.kind = "Rounding")
train_qda <- train(y~., training, method = "qda")
set.seed(4, sample.kind = "Rounding")
train_loess <- train(y~., data = training, method = "gamLoess")
set.seed(5, sample.kind="Rounding")
train_knn <- train(y~.,
data = training,
method = "knn",
tuneGrid = knn_grid)
set.seed(6, sample.kind="Rounding")
train_rf <- train(y~.,
data = training,
method = "rf",
tuneGrid = rf_grid,
trControl = trainControl(method = "cv",
number = 10),
importance = TRUE)
set.seed(7, sample.kind = "Rounding")
# I will use cross validation in the train set in order to find the optimal
# hidden layers and decay.
tc <- trainControl(method = "repeatedcv", number = 10, repeats=3)
train_nn <- train(y~.,
data=training,
method='nnet',
linout=FALSE,
trace = FALSE,
trControl = tc,
tuneGrid = nn_grid)
set.seed(8, sample.kind = "Rounding")
train_rpart <- train(y ~ .,
data = training,
method = "rpart",
trControl = trainControl(method = "cv", number = 10),
tuneGrid = rpart_grid)
val_preds_glm <- predict(train_glm, Val)
val_preds_lda <- predict(train_lda, Val)
val_preds_qda <- predict(train_qda, Val)
val_preds_loess <- predict(train_loess, Val)
val_preds_rf <- predict(train_rf, Val)
val_preds_nn <- predict(train_nn, Val)
val_preds_rp <- predict(train_rpart, Val)
val_preds_knn <- predict(train_knn, Val)
Val_Ensemble <- cbind(glm = val_preds_glm == "B", lda = val_preds_lda == "B",
qda = val_preds_qda == "B", loess = val_preds_loess == "B",
rf = val_preds_rf == "B", nn = val_preds_nn == "B",
rp = val_preds_rp == "B", knn = val_preds_knn == "B")
Val_Ensemble_preds <- ifelse(rowMeans(Val_Ensemble)>0.5, "B", "M")
Val_Acc <- mean(Val_Ensemble_preds == Val$y)
Accuracy_Results <- bind_rows(Accuracy_Results,
tibble(Method = "Ensemble Validation",
Accuracy = Val_Acc))
Accuracy_Results %>% knitr::kable(align='l')| Method | Accuracy |
|---|---|
| GLM | 0.9230769 |
| LDA | 0.9560440 |
| QDA | 0.9670330 |
| gamLoess | 0.9340659 |
| KNN | 0.9450549 |
| RF | 0.9340659 |
| NNet | 0.9560440 |
| RPart | 0.9010989 |
| GLM (7 PCs) | 0.9560440 |
| Ensemble | 0.9670330 |
| Ensemble Validation | 0.9913043 |
Let’s also apply the stacked meta-learner from the test split to this validation ensemble, and compare it against the simple majority vote.
stack_val_df <- as.data.frame(Val_Ensemble) %>% mutate(across(everything(), as.numeric))
stack_val_preds <- predict(train_stack, stack_val_df)
stack_val_acc <- mean(stack_val_preds == Val$y)
Accuracy_Results <- bind_rows(Accuracy_Results,
tibble(Method = "Stacked Ensemble (Validation)",
Accuracy = stack_val_acc))
Accuracy_Results %>% knitr::kable(align='l')| Method | Accuracy |
|---|---|
| GLM | 0.9230769 |
| LDA | 0.9560440 |
| QDA | 0.9670330 |
| gamLoess | 0.9340659 |
| KNN | 0.9450549 |
| RF | 0.9340659 |
| NNet | 0.9560440 |
| RPart | 0.9010989 |
| GLM (7 PCs) | 0.9560440 |
| Ensemble | 0.9670330 |
| Ensemble Validation | 0.9913043 |
| Stacked Ensemble (Validation) | 0.9826087 |
## Confusion Matrix and Statistics
##
## Reference
## Prediction B M
## B 72 2
## M 0 41
##
## Accuracy : 0.9826
## 95% CI : (0.9386, 0.9979)
## No Information Rate : 0.6261
## P-Value [Acc > NIR] : <2e-16
##
## Kappa : 0.9625
##
## Mcnemar's Test P-Value : 0.4795
##
## Sensitivity : 1.0000
## Specificity : 0.9535
## Pos Pred Value : 0.9730
## Neg Pred Value : 1.0000
## Prevalence : 0.6261
## Detection Rate : 0.6261
## Detection Prevalence : 0.6435
## Balanced Accuracy : 0.9767
##
## 'Positive' Class : B
##
## Confusion Matrix and Statistics
##
## Reference
## Prediction B M
## B 72 1
## M 0 42
##
## Accuracy : 0.9913
## 95% CI : (0.9525, 0.9998)
## No Information Rate : 0.6261
## P-Value [Acc > NIR] : <2e-16
##
## Kappa : 0.9813
##
## Mcnemar's Test P-Value : 1
##
## Sensitivity : 1.0000
## Specificity : 0.9767
## Pos Pred Value : 0.9863
## Neg Pred Value : 1.0000
## Prevalence : 0.6261
## Detection Rate : 0.6261
## Detection Prevalence : 0.6348
## Balanced Accuracy : 0.9884
##
## 'Positive' Class : B
##
Finally, since accuracy alone assumes a fixed 0.5 decision threshold, let’s also compute a probability-based ensemble score and its ROC/AUC to check separability independent of any single threshold.
val_probs <- data.frame(
glm = predict(train_glm, Val, type = "prob")$B,
lda = predict(train_lda, Val, type = "prob")$B,
qda = predict(train_qda, Val, type = "prob")$B,
loess = predict(train_loess, Val, type = "prob")$B,
rf = predict(train_rf, Val, type = "prob")$B,
nn = predict(train_nn, Val, type = "prob")$B,
knn = predict(train_knn, Val, type = "prob")$B
)
ensemble_prob <- rowMeans(val_probs)
roc_obj <- roc(response = Val$y, predictor = ensemble_prob, levels = c("M", "B"))
auc(roc_obj)## Area under the curve: 0.9952
Summary
The ensemble approach has yielded an accuracy above 99%, with a 95% confidence interval between 95.25% and 99.98%, and an overall balanced accuracy of 98.84%. The addition of new data for training also provided an improvement in the positive prediction value (benign tumors). Just like the first split, the ensemble method proves 100% accurate for predicting malignant tumors on the final validation hold out.
“In 2020, there were 2.3 million women diagnosed with breast cancer and 685,000 deaths globally. As of the end of 2020, there were 7.8 million women alive who were diagnosed with breast cancer in the past 5 years, making it the world’s most prevalent cancer. There are more lost disability-adjusted life years (DALYs) by women to breast cancer globally than any other type of cancer. Breast cancer occurs in every country of the world in women at any age after puberty but with increasing rates in later life.”
Hopefully, with the aid of machine learning, physicians can more speedily and accurately identify malignant growths via fine needle aspiration.
I hope you have enjoyed reading this project.