Skip to content

Latest commit

 

History

History
509 lines (369 loc) · 13.4 KB

File metadata and controls

509 lines (369 loc) · 13.4 KB
title Principal component analysis: examples
subtitle Introduction to Statistical Modelling
author Prof. Joris Vankerschaver
```{r, include=FALSE} set.seed(1234) library(tidyverse) library(readxl) library(pls) library(loon.data) theme_set(theme_bw() + theme(text = element_text(size = 14))) # Read oils data oils <- read_excel("./datasets/02b-pca-applications/HSI.xlsx") cols <- colnames(oils) spectra <- cols[5:length(cols)] oils_long <- oils %>% pivot_longer(cols = spectra, names_to = "wavelength", values_to = "intensity") %>% mutate(wavelength = as.numeric(wavelength)) # PCA on spectral data pca_oils <- oils %>% select(all_of(spectra)) %>% prcomp(scale = FALSE) # Train/test split for adulterated oils adulterated <- oils %>% filter(`% Adulteration` > 0, `% Adulteration` <= 20) %>% select(-`Sample ID/Wavelength`, -Sample, -Classification) train_no <- round(0.8 * nrow(adulterated)) train_idxs <- sample(1:nrow(adulterated), train_no) adulterated_train <- adulterated[train_idxs, ] adulterated_test <- adulterated[-train_idxs, ] # Build PCR and PLS models pcr_model <- pcr( `% Adulteration` ~ ., data = adulterated_train, scale = FALSE, validation = "CV", ncomp = 10 ) pls_model <- plsr( `% Adulteration` ~ ., data = adulterated_train, scale = FALSE, validation = "CV", ncomp = 10 ) # Faces dataset data(faces) pc_olivetti <- prcomp(faces) show_image <- function(imgdata, title = NULL) { m <- matrix(imgdata, nrow = 64, ncol = 64, byrow = F) m <- t(apply(m, 2, rev)) image(m, axes = FALSE, col = grey(seq(0, 1, length = 256)), main = title) } normalize <- function(x) { 255 * (x - min(x)) / (max(x) - min(x)) } project_data <- function(pc, n_retain) { t(t(pc$x[, 1:n_retain] %*% t(pc$rotation)[1:n_retain, ]) + pc$center) } ``` ## Examples 1. Adulteration of olive oil - Malavi, Derick, Amin Nikkhah, Katleen Raes, and Sam Van Haute. 2023. "Hyperspectral Imaging and Chemometrics for Authentication of Extra Virgin Olive Oil: A Comparative Approach with FTIR, UV-VIS, Raman, and GC-MS.” Foods 12 (3): 429. \url{https://doi.org/10.3390/foods12030429} 2. Human faces dataset - \url{https://scikit-learn.org/0.19/datasets/olivetti_faces.html} # Adulteration of olive oil ## Problem setting :::: {.columns} ::: {.column width="50%"} Extra virgin olive oil (EVOO): \vspace*{0.5cm} - High quality - Flavorful - Health benefits - **More expensive** (than regular oil) \vspace*{1cm} To reduce cost, EVOO is often **adulterated** with other, cheaper food oils. ::: ::: {.column width="50%"} ![](./images/02b-pca-applications/olive-oil.jpg){height=2in fig-align=center} ::: :::: ## Research questions 1. **Classification:** Can we detect whether a given EVOO sample has been adulterated? - Yes/no answer (categorical) 2. **Regression:** Can we detect the degree of adulteration? - Continuous answer, from 0% (no adulteration) to 100% ## Hyperspectral imaging (HSI) ![](./images/02b-pca-applications/hyperspectral.png){fig-align=center height=50%} - Measures reflected infrared light (700-1800 nm) off sample - Provides a non-destructive way of testing sample ## Hyperspectral "images" (spectra) ![](./images/02b-pca-applications/hsi-spectra.png){fig-align=center height=50%} - HSI measures reflectance at 224 wavelengths from 700 to 1800 nm - Reflectance at given wavelength is determined by molecular features of sample ## Experimental setup Samples to test (61 total): - 13 different kinds of unadulterated EVOO - 6 vegetable oils - 42 adulterated mixtures - EVOO + one of 6 vegetable oils at one of 7 different percentages (from 1% to 20%) Each sample is imaged 3 times: **183 samples** Each sample produces a HSI spectrum of **length 224** ## Data matrix Data matrix has 183 rows (samples) and 224 columns (spectra). In addition, we have some metadata: - Name of sample - Degree of adulteration ![](./images/02b-pca-applications/dataset.png) ## A first look at the data Averaged spectra for each kind of oil (EVOO + 6 others) ```{r} #| out-height: 2in #| fig-width: 6 #| fig-height: 3 #| fig-align: center #| warning: false summarized_oils_long <- oils_long %>% filter(`% Adulteration` == 0 | `% Adulteration` == 100) %>% mutate(type = if_else(Classification == "Olive", "EVOO", `Sample ID/Wavelength`)) %>% group_by(type, wavelength) %>% summarise(mean_intensity = mean(intensity)) ggplot( summarized_oils_long, aes(x = wavelength, y = mean_intensity, color = type)) + geom_line() + labs(x = "Wavelength (nm)", y = "Mean reflectance", color = "Type of oil") ``` Plot shows small differences between spectra: **promising sign** that we will be able to address the research questions. ## Principal component analysis: scree plot Not all 224 wavelengths are equally informative. Much of our dataset is redundant. ```{r} #| out-height: 2in #| fig-width: 4 #| fig-height: 3 #| fig-align: center pca_oils %>% broom::tidy(matrix = "eigenvalues") %>% head(n = 9) %>% ggplot(aes(PC, percent)) + geom_col(fill = "#56B4E9", alpha = 0.8) + scale_x_continuous(breaks = 1:9) + scale_y_continuous( labels = scales::percent_format(), expand = expansion(mult = c(0, 0.01)) ) + labs(y = "Pct of variance explained") ``` This is confirmed by the scree plot: - First 2 PCs explain **94% of variance** in the data - First 3 PCs: almost 100% ## Principal component analysis: loadings vectors Loadings vectors are linear combinations of features, tell us how features contribute to variability in dataset. ```{r} #| out-height: 2in #| fig-width: 6 #| fig-height: 3 #| fig-align: center loadings <- as.data.frame(pca_oils$rotation) loadings$wavelength <- as.numeric(rownames(loadings)) rownames(loadings) <- 1:nrow(loadings) loadings %>% select(wavelength, PC1, PC2) %>% pivot_longer(cols = c(PC1, PC2)) %>% ggplot() + geom_line(aes(x = wavelength, y = value, color = name), linewidth = 1.0) + labs(x = "Wavelength", y = "", color = "Component") ``` For our example: - Loadings vector 1: where do spectra differ the most? - Loadings vector 2: where is next source of variability located? ## Principal component analysis: scores ```{r} #| out-height: 2in #| fig-width: 5 #| fig-height: 4 # note: deliberately made the plot larger #| fig-align: center pdata <- pca_oils %>% broom::augment(oils) %>% filter(`% Adulteration` < 100) %>% mutate(adulterated = `% Adulteration` > 0) pct_var_explained <- 100*pca_oils$sdev^2/sum(pca_oils$sdev^2) xlabel <- paste0("PC 1 (", round(pct_var_explained[[1]], 2), "% var. explained)") ylabel <- paste0("PC 2 (", round(pct_var_explained[[2]], 2), "% var. explained)") ggplot( pdata, aes(.fittedPC1, .fittedPC2, color = `% Adulteration`, shape = adulterated)) + geom_point(size = 2) + labs(x = xlabel, y = ylabel, shape = "Type of oil") + scale_shape_discrete(labels = c("Pure", "Adulterated")) ``` Can we tell pure and adulterated samples apart? - **Yes**: clearly different on score plot. Can we predict the percentage of adulteration? - **No**: hard to distinguish from first 2 PCs alone. ## Predicting the percentage of adulteration We will need more than 2 PCs to correctly predict percentage of adulteration. Two different approaches: - **Principal component regression**: 1. Compute PCs 2. Do a regression on PCs - **Partial least squares regression**: 1. Compute factors that are most variable and **most correlated with outcome** 2. Do a regression on resulting factors Both models can be built using the `pls` package in R. ## Dataset For this example we will use only the 42 adulterated mixtures. Each mixture is imaged 3 times: $42 \times 3 = 126$ samples Predictors: 224 wavelengths Outcome: percentage of adulteration (1%-20%) ## Performing a fair assessment: train/test split Evaluating the model using the same data used to train it leads to an **optimistic** estimate of the model's performance. To avoid this bias, randomly select and set aside some data for testing, and use the remaining data to develop the model. ![](./images/02b-pca-applications/traintest.pdf){fig-align=center} Adulteration prediction: - Train dataset: 101 samples - Test dataset: 25 samples Can you spot an issue with this? ## Performing a fair assessment: data leakage - Each of the 42 mixtures is imaged 3 times. - Presumably these replicates are very similar - If some replicates end up in the test dataset and some in the train dataset: model gains unfair advantage. ![](./images/02b-pca-applications/traintest-unstratified.pdf){fig-align=center} ## Avoiding data leakage: stratified train/test split Main idea: develop model with some of the mixtures, test performance on different mixtures: 1. Randomly select 80% of **mixtures** 2. Put all 3 replicates for those 80% in the training set 3. Put the remainder in the test set. ![](./images/02b-pca-applications/traintest-stratified.pdf){fig-align=center} ## Building the PCR/PLS models PCR model: ```default pcr_model <- pcr( `% Adulteration` ~ ., data = adulterated_train, scale = FALSE, validation = "CV", ncomp = 10 ) ``` PLS model: replace `pcr` by `plsr`. Arguments: - `scale = FALSE`: Don't scale spectra (same units) - `ncomp = 10`: Build model with up to 10 components - `validation = "CV"`: Assess performance of model with $i$ components using cross-validation ## Performance of PCR/PLS models ```{r} #| out-height: 2in #| fig-width: 4 #| fig-height: 3 #| fig-align: center #| warning: false pcr_pred <- predict(pcr_model, adulterated_test, ncomp = 10) pls_pred <- predict(pls_model, adulterated_test, ncomp = 10) df <- data.frame( measured = adulterated_test$`% Adulteration`, PLS = unlist(as.list(pls_pred)), PCR = unlist(as.list(pcr_pred)) ) %>% pivot_longer(cols = c("PLS", "PCR")) jitter_x <- position_jitter(w = 0.15, h = 0) ggplot(df) + geom_abline(alpha = 0.3) + geom_point(aes(x = measured, y = value, color = name), alpha = 1.0, position = jitter_x) + labs(color = "Method", x = "Measured", y = "Predicted") ``` Both models do well on the test data. ## Optimal number of components: PCR (obtained via `selectNcomp(method = "onesigma")`) ```{r} #| out-height: 2in #| fig-width: 6 #| fig-height: 3 #| fig-align: center selectNcomp(pcr_model, method = "onesigma", plot = TRUE) ``` - Optimal number of components: 7 - RMSEP for 7 components: 1.796 ## Optimal number of components: PLS ```{r} #| out-height: 2in #| fig-width: 6 #| fig-height: 3 #| fig-align: center selectNcomp(pls_model, method = "onesigma", plot = TRUE) ``` - Optimal number of components: 7 - RMSEP for 7 components: 1.627 ## Conclusions *Can we detect whether a given EVOO sample has been adulterated?* - **Yes**: Look at score plot - More conclusive answer next lecture *Can we detect the degree of adulteration?* - **Yes**: Build PCR or PLS model # Human faces dataset ## The Olivetti faces dataset - 400 frontal photographs of human faces - Grayscale images, 64 $\times$ 64 pixels - Each pixel: value between 0 (black) and 255 (white) \vspace*{0.5cm} Each image can be "unrolled" into a vector with $64 \times 64 = 4096$ entries. ![](./images/02b-pca-applications/image-unroll.pdf){height=2in fig-align=center} ## Sample faces ```{r} #| out-height: 2in #| fig-width: 6 #| fig-height: 3 #| fig-align: center par(mfrow = c(2, 4), mar = c(1, 1, 1, 1)) for (i in 1:8) { show_image(faces[, 10*i], paste0("Face #", 10*i)) } ``` - Data matrix: 400 rows $\times$ 4096 columns - Each row is one face (as a vector) ## Scree plot ```{r} #| out-height: 2in #| fig-width: 5 #| fig-height: 3 #| fig-align: center #| warning: false sdev <- pc_olivetti$sdev[1:50] var_explained <- sdev^2 / sum(pc_olivetti$sdev^2) total_var <- cumsum(var_explained) df_var <- data.frame(n = 1:50, v = var_explained, t = total_var) ggplot(df_var) + geom_line(aes(x = n, y = v, color = "Per component")) + geom_point(aes(x = n, y = v, color = "Per component"), size = 0.8) + geom_line(aes(x = n, y = t, color = "Cumulative")) + geom_point(aes(x = n, y = t, color = "Cumulative"), size = 0.8) + ylim(c(0, 1)) + scale_color_manual( name = "Explained variance", breaks = c("Per component", "Cumulative"), values = c("Per component" = "cornflowerblue", "Cumulative" = "chocolate") ) + labs(x = "Principal component", y = "Explained variance (%)") ``` - 400 PCs in total, but most contribute almost no variance - We can discard most PCs without losing much information ## Loadings vectors as "eigenfaces" ```{r} #| out-height: 2in #| fig-width: 6 #| fig-height: 3 #| fig-align: center par(mfrow = c(2, 4), mar = c(1, 1, 1, 1)) for (i in 1:8) { show_image(normalize(pc_olivetti$x[, i]), paste0("PC ", i)) } ``` - Each loadings vector represents a **pattern** in the dataset - PC 1: overall face structure; PC 2: left-right illumination; ... - By mixing all loadings vectors, we can recreate any face ## PCA as data compression ```{r} #| out-height: 2in #| fig-width: 6 #| fig-height: 3 #| fig-align: center par(mfrow = c(2, 4), mar = c(1, 1, 1, 1)) show_image(faces[, 70], "Original") show_image(project_data(pc_olivetti, 10)[, 70], "10 PCs") show_image(project_data(pc_olivetti, 40)[, 70], "40 PCs") show_image(project_data(pc_olivetti, 80)[, 70], "80 PCs") show_image(faces[, 80], "Original") show_image(project_data(pc_olivetti, 10)[, 80], "10 PCs") show_image(project_data(pc_olivetti, 40)[, 80], "40 PCs") show_image(project_data(pc_olivetti, 80)[, 80], "80 PCs") ``` - Original: 4096 degrees of freedom - 80 PCs: more than **50$\times$ reduction** — faces still clearly recognizable