Chapter 7: Unsupervised Learning

library(ca)
library(cluster)
library(ellipse)
library(ggrepel)
library(mclust)
library(tidyclust)
library(tidymodels)
library(tidyverse)

set.seed(123)

# Location of the data files. Adjust this path if you keep the data
# files in a different directory.
DATA_DIR <- '../data'

Unsupervised Learning

Principal Components Analysis

A Simple Example

sp500_px <- read_csv(file.path(DATA_DIR, "sp500_data.csv.gz"), show_col_types = FALSE)

oil_px <- sp500_px[, c("CVX", "XOM")]
pca <- princomp(oil_px)
pca$loadings

Loadings:
    Comp.1 Comp.2
CVX  0.747  0.665
XOM  0.665 -0.747

               Comp.1 Comp.2
SS loadings       1.0    1.0
Proportion Var    0.5    0.5
Cumulative Var    0.5    1.0
pca_rec <- recipe(data = sp500_px, formula = ~ CVX + XOM) %>%
  step_pca(all_numeric_predictors())
pca_prep <- pca_rec %>%
  prep()
pca_prep$steps[[1]]$rotation
NULL
loadings <- pca$loadings
ggplot(data = oil_px, aes(x = CVX, y = XOM)) +
  geom_point(alpha = 0.3) +
  stat_ellipse(type = "norm", level = 0.99) +
  geom_abline(intercept = 0, slope = loadings[2, 1] / loadings[1, 1]) +
  geom_abline(intercept = 0, slope = loadings[2, 2] / loadings[1, 2]) +
  coord_cartesian(xlim = c(-3, 3), ylim = c(-3, 3))

Interpreting Principal Components

syms <- c("AAPL", "MSFT", "CSCO", "INTC", "CVX", "XOM", "SLB",
  "COP", "JPM", "WFC", "USB", "AXP", "WMT", "TGT", "HD", "COST")
top_sp <- sp500_px %>%
  filter(Date >= "2011-01-01") %>%
  select(all_of(syms))
sp_pca <- princomp(top_sp)
screeplot(sp_pca)

components <- 1:length(sp_pca$sdev)
g <- tibble(
  Components = components,
  Variances = sp_pca$sdev ** 2,
) |>
filter(
  Components < 11,
) |>
  ggplot(aes(x=Components, y=Variances)) +
    geom_bar(stat="identity") +
    coord_cartesian(xlim = c(0.5, 10.49)) +
    scale_x_continuous(breaks = components) +
    theme_bw()
g

loadings <- sp_pca$loadings[, 1:5]
loadings <- as_tibble(loadings) %>%
  mutate(Symbol = row.names(loadings)) %>%
  pivot_longer(cols = starts_with("Comp"), names_to = "Component",
    values_to = "Weight") %>%
  mutate(color = Weight > 0)
ggplot(loadings, aes(x = Symbol, y = Weight, fill = color)) +
  geom_bar(stat = "identity") +
  facet_grid(Component ~ ., scales = "free_y") +
  guides(fill = "none") +
  labs(y = "Component Loading") +
  theme_bw() +
  theme(axis.title.x = element_blank(),
    axis.text.x = element_text(angle = 90, vjust = 0.5))

Correspondence Analysis

housetasks <- read_csv(file.path(DATA_DIR, "housetasks.csv"), show_col_types = FALSE) %>%
  column_to_rownames(var = "Task")
ca_analysis <- ca(housetasks)
plot(ca_analysis)

K-Means Clustering

A Simple Example

df <- sp500_px %>%
  filter(Date >= "2011-01-01")
kmeans_wf <- workflow() %>%
  add_recipe(recipe(~ XOM + CVX, data = df)) %>%
  add_model(k_means(num_clusters = 4))
model <- kmeans_wf %>% fit(df)
augment(model, df) %>%
  select(Date, XOM, CVX, .pred_cluster) %>%
  head()
# A tibble: 6 × 4
  Date          XOM    CVX .pred_cluster
  <date>      <dbl>  <dbl> <fct>        
1 2011-01-03 0.737   0.241 Cluster_1    
2 2011-01-04 0.169  -0.585 Cluster_2    
3 2011-01-05 0.0266  0.447 Cluster_1    
4 2011-01-06 0.249  -0.920 Cluster_2    
5 2011-01-07 0.337   0.181 Cluster_1    
6 2011-01-10 0      -0.464 Cluster_2    
tidy(model)
# A tibble: 4 × 5
     XOM    CVX  size withinss cluster
   <dbl>  <dbl> <int>    <dbl> <fct>  
1  0.326  0.450   469    123.  1      
2 -0.286 -0.493   421    115.  2      
3  1.09   1.58    123     85.2 3      
4 -1.12  -1.72    118     87.9 4      
ggplot(augment(model, df), aes(x = XOM, y = CVX)) +
  geom_point(aes(color = .pred_cluster, shape = .pred_cluster), alpha = 0.3) +
  geom_point(data = tidy(model), aes(x = XOM, y = CVX), size = 3, stroke = 2)

K-Means Algorithm

syms <- c("AAPL", "MSFT", "CSCO", "INTC", "CVX", "XOM", "SLB", "COP",
  "JPM", "WFC", "USB", "AXP", "WMT", "TGT", "HD", "COST")
df <- sp500_px %>%
  filter(Date >= "2011-01-01") %>%
  select(all_of(syms))
set.seed(10010)
kmeans_wf <- workflow() %>%
  add_recipe(recipe(~ ., data = df)) %>%
  add_model(k_means(num_clusters = 5) %>%
      set_engine("stats", nstart = 10))
km <- kmeans_wf %>% fit(df)

Interpreting the Clusters

tidy(km)$size
[1] 287 267 293 178 106
centers <- tidy(km) %>%
  pivot_longer(cols = all_of(syms), names_to = "Symbol",
    values_to = "Mean") %>%
  mutate(Color = Mean > 0)
ggplot(centers, aes(x = Symbol, y = Mean, fill = Color)) +
  geom_bar(stat = "identity", position = "identity", width = 0.75) +
  facet_grid(cluster ~ ., scales = "free_y") +
  guides(fill = "none") +
  labs(y = "Component Loading") +
  theme_bw() +
  theme(axis.title.x = element_blank(),
    axis.text.x = element_text(angle = 90, vjust = 0.5))

Selecting the Number of Clusters

set.seed(10010)
train_kmeans <- function(num_clusters, data) {
  model <- workflow() %>%
    add_recipe(recipe(~ ., data = data)) %>%
    add_model(k_means(num_clusters = num_clusters) %>%
        set_engine("stats", nstart = 50, iter.max = 100)) %>%
    fit(data)
  return(model)
}
km <- train_kmeans(14, df)
totalss <- extract_fit_engine(km)$totss

pct_var <- tibble(pct_var = 0, num_clusters = 2:14)
for (num_clusters in 2:14) {
  km_cluster <- train_kmeans(num_clusters, df)
  pct_var[num_clusters - 1, "pct_var"] <- (
    extract_fit_engine(km_cluster)$betweenss / totalss)
}
ggplot(pct_var, aes(x = num_clusters, y = pct_var)) +
  geom_line() +
  geom_point() +
  labs(x = "Number of clusters (k)", y = "% Variance Explained")

Hierarchical Clustering

A Simple Example

syms1 <- c("GOOGL", "AMZN", "AAPL", "MSFT", "CSCO", "INTC", "CVX", "XOM", "SLB",
  "COP", "JPM", "WFC", "USB", "AXP", "WMT", "TGT", "HD", "COST")
df <- sp500_px %>%
  filter(Date >= "2011-01-01") %>%
  select(all_of(syms1)) %>%
  t() # take transpose: to cluster companies, we need the stocks along the rows

d <- dist(df)
hcl <- hclust(d)

The Dendrogram

plot(hcl)

plot(hcl, ylab = "distance", xlab = "", sub = "", main = "")

cutree(hcl, k = 4)
GOOGL  AMZN  AAPL  MSFT  CSCO  INTC   CVX   XOM   SLB   COP   JPM   WFC   USB 
    1     2     3     3     3     3     4     4     4     4     3     3     3 
  AXP   WMT   TGT    HD  COST 
    3     3     3     3     3 

Model-Based Clustering

Mixtures of Normals

df <- sp500_px %>%
  filter(Date >= "2011-01-01") %>%
  select(c(XOM, CVX))
mcl <- Mclust(df)
summary(mcl)
---------------------------------------------------- 
Gaussian finite mixture model fitted by EM algorithm 
---------------------------------------------------- 

Mclust VEE (ellipsoidal, equal shape and orientation) model with 2 components: 

 log-likelihood    n df       BIC       ICL
      -2255.125 1131  9 -4573.528 -5075.657

Clustering table:
  1   2 
168 963 
cluster <- factor(predict(mcl)$classification)
ggplot(df, aes(x = XOM, y = CVX, color = cluster, shape = cluster)) +
  geom_point(alpha = 0.8) +
  scale_shape_manual(
    values = c(1, 3),
    guide = guide_legend(override.aes = aes(size = 2)))

summary(mcl, parameters = TRUE)$mean
           [,1]       [,2]
XOM -0.04362218 0.05792282
CVX -0.21109525 0.07375447
summary(mcl, parameters = TRUE)$variance
, , 1

         XOM      CVX
XOM 1.044671 1.065190
CVX 1.065190 1.912748

, , 2

          XOM       CVX
XOM 0.2998935 0.3057838
CVX 0.3057838 0.5490920

Selecting the Number of Clusters

plot(mcl, what = "BIC", ask = FALSE)

plot(mcl, what = "BIC", ask = FALSE)

Scaling and Categorical Variables

Scaling the Variables

loan_data <- read_csv(file.path(DATA_DIR, "loan_data.csv.gz"), show_col_types = FALSE) %>%
  mutate(
    across(where(is.character), as.factor),
    outcome = factor(outcome, levels = c("paid off", "default"))
  )
defaults <- loan_data %>%
  filter(outcome == "default") %>%
  select(c(loan_amnt, annual_inc, revol_bal, open_acc, dti, revol_util))
km <- kmeans(defaults, centers = 4, nstart = 10)
centers <- data.frame(size = km$size, km$centers)
round(centers, digits = 2)
   size loan_amnt annual_inc revol_bal open_acc   dti revol_util
1 13819  10577.04   42380.98  10245.27     9.58 17.71      58.09
2  1221  21797.26  164503.32  38652.54    12.61 13.53      63.65
3  7579  18247.71   83069.61  19587.30    11.66 16.79      62.26
4    52  22570.19  489783.40  85161.35    13.33  6.91      59.65
df0 <- scale(df)
km0 <- kmeans(df0, centers = 4, nstart = 10)
centers0 <- scale(km0$centers, center = FALSE,
  scale = 1 / attr(df0, "scaled:scale"))
centers0 <- scale(centers0, center = -attr(df0, "scaled:center"),
  scale = FALSE)
centers0 <- data.frame(size = km0$size, centers0)
round(centers0, digits = 2)
  size   XOM   CVX
1  131  1.17  1.45
2  420 -0.29 -0.44
3  450  0.33  0.45
4  130 -1.13 -1.62

Dominant Variables

syms <- c("GOOGL", "AMZN", "AAPL", "MSFT", "CSCO", "INTC", "CVX", "XOM", "SLB",
  "COP", "JPM", "WFC", "USB", "AXP", "WMT", "TGT", "HD", "COST")
top_sp1 <- sp500_px %>%
  filter(Date >= "2011-01-01") %>%
  select(all_of(syms))
sp_pca1 <- princomp(top_sp1)
screeplot(sp_pca1)

components <- 1:length(sp_pca1$sdev)
g <- tibble(
  Components = components,
  Variances = sp_pca1$sdev ** 2,
) |>
filter(
  Components < 11,
) |>
  ggplot(aes(x=Components, y=Variances)) +
    geom_bar(stat="identity") +
    coord_cartesian(xlim = c(0.5, 10.49)) +
    scale_x_continuous(breaks = components) +
    theme_bw()
g

round(sp_pca1$loadings[, 1:2], 3)
      Comp.1 Comp.2
GOOGL  0.781  0.609
AMZN   0.593 -0.792
AAPL   0.078  0.004
MSFT   0.029  0.002
CSCO   0.017 -0.001
INTC   0.020 -0.001
CVX    0.068 -0.021
XOM    0.053 -0.005
SLB    0.079 -0.013
COP    0.044 -0.016
JPM    0.043  0.001
WFC    0.034 -0.001
USB    0.026  0.003
AXP    0.063 -0.006
WMT    0.026 -0.001
TGT    0.036 -0.010
HD     0.051 -0.019
COST   0.061 -0.019

Categorical Data and Gower’s Distance

x <- loan_data[1:5, c("dti", "payment_inc_ratio", "home_", "purpose_")]
x
# A tibble: 5 × 4
    dti payment_inc_ratio home_ purpose_          
  <dbl>             <dbl> <fct> <fct>             
1  1                 2.39 RENT  major_purchase    
2  5.55              4.57 OWN   small_business    
3 18.1               9.72 RENT  other             
4 10.1              12.2  RENT  debt_consolidation
5  7.06              3.91 RENT  other             
daisy(x, metric = "gower")
Dissimilarities :
          1         2         3         4
2 0.6220479                              
3 0.6863877 0.8143398                    
4 0.6329040 0.7608561 0.4307083          
5 0.3772789 0.5389727 0.3091088 0.5056250

Metric :  mixed ;  Types = I, I, N, N 
Number of objects : 5
set.seed(1234)
defaults <- loan_data %>%
  filter(outcome == "default")
df <- defaults[sample(nrow(defaults), 250),
  c("dti", "payment_inc_ratio", "home_", "purpose_")]
d <- daisy(df, metric = "gower")
hcl <- hclust(d)
dnd <- as.dendrogram(hcl)
plot(dnd, leaflab = "none", ylab = "distance")

plot(dnd, leaflab = "none", ylab = "distance")

dnd_cut <- cut(dnd, h = 0.5)
df[labels(dnd_cut$lower[[1]]), ]
# A tibble: 20 × 4
     dti payment_inc_ratio home_ purpose_
   <dbl>             <dbl> <fct> <fct>   
 1  3.61             3.60  RENT  other   
 2  0                0.711 RENT  other   
 3 25.5             14.0   RENT  other   
 4 28.4             12.8   RENT  other   
 5 25.4              4.06  RENT  other   
 6 25.2              6.35  RENT  other   
 7 27.5              9.75  RENT  other   
 8 30.1              6.61  RENT  other   
 9 16.4             12.0   RENT  other   
10 15.4             16.5   RENT  other   
11 20.4              7.04  RENT  other   
12 21.3              1.91  RENT  other   
13 19.7              4.31  RENT  other   
14 17.0              4.04  RENT  other   
15 14.5              2.58  RENT  other   
16 11.8              1.98  RENT  other   
17 10.0              2.58  RENT  other   
18  9.87             4.55  RENT  other   
19 16.0              5.27  RENT  other   
20 13.9              5.26  RENT  other   

Problems with Clustering Mixed Data

df <- model.matrix(~ -1 + dti + payment_inc_ratio + home_ + pub_rec_zero,
  data = defaults)
df0 <- scale(df)
km0 <- kmeans(df0, centers = 4, nstart = 10)
centers0 <- scale(km0$centers, center = FALSE,
  scale = 1 / attr(df0, "scaled:scale"))
round(scale(centers0, center = - attr(df0, "scaled:center"), scale = FALSE), 2)
    dti payment_inc_ratio home_MORTGAGE home_OWN home_RENT pub_rec_zero
1 17.20              9.27          0.00        1      0.00         0.92
2 17.46              8.42          1.00        0      0.00         1.00
3 16.99              9.11          0.00        0      1.00         1.00
4 16.50              8.06          0.52        0      0.48         0.00
attr(,"scaled:scale")
              dti payment_inc_ratio     home_MORTGAGE          home_OWN 
        0.1305561         0.2286345         2.0190809         3.6191450 
        home_RENT      pub_rec_zero 
        2.0008117         3.5722842 
attr(,"scaled:center")
              dti payment_inc_ratio     home_MORTGAGE          home_OWN 
      -17.1521684        -8.7700843        -0.4313440        -0.0832782 
        home_RENT      pub_rec_zero 
       -0.4853778        -0.9142958 

Supplementary Material

Figure 7-4. Graphical representation of a correspondence analysis of house task data

set.seed(1234)
contrib <- ca_analysis$sv ** 2
contrib <- contrib / sum(contrib)
colcoord <- as.data.frame(ca_analysis$colcoord)
rowcoord <- as.data.frame(ca_analysis$rowcoord)
coords <- bind_rows(
  rowcoord %>% mutate(type = "rowcoord"),
  colcoord %>% mutate(type = "columns")
)
row.names(coords) <- gsub("_", " ", row.names(coords))

graph <- ggplot(coords, aes(x = Dim1, y = Dim2, color = type,
    label = rownames(coords), shape = type)) +
  geom_hline(yintercept = 0, linetype = "dotted", color = "#444444") +
  geom_vline(xintercept = 0, linetype = "dotted", color = "#444444") +
  geom_point() +
  geom_text_repel() +
  xlab(sprintf("Dimension 1 (%.1f%%)", 100 * contrib[1])) +
  ylab(sprintf("Dimension 2 (%.1f%%)", 100 * contrib[2])) +
  scale_color_manual(values = c("blue", "red")) +
  theme_bw() +
  theme(legend.position = "none")
graph

Figure 7-5. The clusters of K-means applied to daily stock returns for ExxonMobil and Chevron

set.seed(1010103)
df <- sp500_px %>%
  filter(Date >= "2011-01-01") %>%
  select(XOM, CVX)
km <- kmeans(df, centers = 4, nstart = 1)
df <- df %>% mutate(cluster = factor(km$cluster))
centers <- as.tibble(km$centers) %>%
  mutate(cluster = factor(1:4))
Warning: `as.tibble()` was deprecated in tibble 2.0.0.
ℹ Please use `as_tibble()` instead.
ℹ The signature and semantics have changed, see `?as_tibble`.
ggplot(df, aes(x = XOM, y = CVX, color = cluster, shape = cluster)) +
  geom_point() +
  scale_shape_manual(
    values = c(1, 3, 2, 4),
    guide = guide_legend(override.aes = aes(size = 1))
  ) +
  geom_point(data = centers, aes(x = XOM, y = CVX), size = 2, stroke = 2, color = "black") +
  coord_cartesian(xlim = c(-2, 2), ylim = c(-2.5, 2.5)) +
  scale_x_continuous(expand = c(0, 0)) +
  scale_y_continuous(expand = c(0, 0))

Figure 7-9. A comparison of measures of dissimilarity applied to stock data

cluster_fun <- function(df, method) {
  d <- dist(df)
  hcl <- hclust(d, method = method)
  tree <- cutree(hcl, k = 4)
  return(df %>% mutate(cluster = factor(tree), method = method))
}

df0 <- sp500_px %>%
  filter(Date >= "2011-01-01") %>%
  select(XOM, CVX)
df <- bind_rows(
  cluster_fun(df0, method = "single"),
  cluster_fun(df0, method = "average"),
  cluster_fun(df0, method = "complete"),
  cluster_fun(df0, method = "ward.D")
)
df$method <- ordered(df$method, c("single", "average", "complete", "ward.D"))

graph <- ggplot(data = df, aes(x = XOM, y = CVX, color = cluster, shape = cluster)) +
  geom_point(alpha = 0.6) +
  scale_shape_manual(
    values = c(1, 3, 4, 2),
    guide = guide_legend(override.aes = aes(size = 2))
  ) +
  facet_wrap(~ method)
graph

Figure 7-10. Probability contours for a two-dimensional normal distribution

mu <- c(0.5, -0.5)
sigma <- matrix(c(1, 1, 1, 2), nrow = 2)
prob <- c(0.5, 0.75, 0.95, 0.99) ## or whatever you want
names(prob) <- prob ## to get id column in result
df <- tibble()
for (p in prob){
  df <- bind_rows(
    df,
    as.tibble(ellipse(x = sigma, centre = mu, level = p)) %>%
      mutate(prob = p)
  )
}
names(df) <- c("X", "Y", "Prob")

## Figure 7-9: Multivariate normal ellipses
dfmu <- tibble(X = mu[1], Y = mu[2])

graph <- ggplot(df, aes(X, Y)) +
  geom_path(aes(linetype = factor(Prob))) +
  geom_point(data = dfmu, aes(X, Y), size = 3)
graph