distances and scaling

Distance analysis uses the concept of a distance metric (Euclidean, Manhattan, correlation, cosine, or a metric suited to abundance, presence and absence, or mixed data) to convert a table of samples and variables into a distance matrix holding one value for every pair of samples, with scaling determining how much each variable contributes to those values. This analysis can be used to answer questions such as “Under the chosen metric, which sample in this reference set is closest to the unknown?” or “Does scaling the variables change which pairs of samples are closest?”


Many of the analyses in the rest of this book start from one question: which samples are alike? A similarity network joins samples that are alike, hierarchical clustering merges them into a tree, k-means and dbscan sort them into groups, ordination methods such as MDS place them near one another on a plot, and matching an unknown GC-MS spectrum or retrieving a related protein from a library means finding the entry most like a query. Every one of those methods runs on a distance matrix: a table holding one number for every pair of samples that says how different the two are. This chapter is about building that matrix well, which comes down to two decisions that are easy to make by accident. Scaling decides which variables count. The distance metric decides what “similar” means. Neither choice has a single right answer, but both change the result, so both should be made on purpose.

distance matrices

A distance matrix compares every sample with every other sample across all of the measured variables at once, and boils each comparison down to a single number, as in the illustration at the top of this chapter. Small numbers mean similar samples.

First, we need our data set in wide format, with one row per sample and one column per variable:

alaska_lake_data %>%
    select(-element_type) %>%
    pivot_wider(names_from = "element", values_from = "mg_per_L") -> alaska_lake_data_wide
head(alaska_lake_data_wide)
## # A tibble: 6 × 15
##   lake       park  water_temp    pH     C     N     P     Cl
##   <chr>      <chr>      <dbl> <dbl> <dbl> <dbl> <dbl>  <dbl>
## 1 Devil_Mou… BELA        6.46  7.69   3.4 0.028 0      10.4 
## 2 Imuruk_La… BELA       17.4   6.44   4.7 0.013 0       1.18
## 3 Kuzitrin_… BELA        8.06  7.45   2   0     0       0.67
## 4 Lava_Lake  BELA       20.2   7.42   8.3 0.017 0.001   2.53
## 5 North_Kil… BELA       11.3   8.04   4.3 0.037 0.001 337.  
## 6 White_Fis… BELA       12.0   7.82  12.3 0.034 0.006 105.  
## # ℹ 7 more variables: S <dbl>, F <dbl>, Br <dbl>, Na <dbl>,
## #   K <dbl>, Ca <dbl>, Mg <dbl>

Now we can compute a distance matrix. Here is the traditional way, using base R:

dist(alaska_lake_data_wide[,3:15])
##             1          2          3          4          5
## 2   17.249885                                            
## 3   13.914769   9.825572                                 
## 4   18.650639  12.114748  17.376585                      
## 5  363.367406 375.484304 376.096169 372.814170           
## 6  107.196874 119.469450 120.216457 116.204115 257.046356
## 7   39.939919  43.719116  42.365857  34.754470 375.000861
## 8   19.404912  21.984690  20.063580  18.697259 373.762458
## 9   35.533288  39.789532  38.784007  30.645412 372.279208
## 10  18.962699  14.634495  15.666485  10.483673 375.971844
## 11  18.453129  11.246167  14.445096   6.035171 375.825483
## 12  15.859597   8.463470   6.549584  14.247034 408.973609
## 13  53.990906  57.852872  56.692667  47.946077 375.976344
## 14  27.647239  27.763703  27.745856  19.099653 375.370523
## 15  53.952032  58.544454  57.044065  50.265090 375.483212
## 16  13.653806  15.851229   7.656391  19.617519 375.983281
## 17  14.509806  16.700167  10.740051  19.120889 375.673721
## 18  13.153727  13.931840   8.987793  15.893494 375.157921
## 19  18.520826  10.826147  14.818691   4.351309 375.348981
## 20  13.122339  13.918487   9.649135  14.221515 374.915449
##             6          7          8          9         10
## 2                                                        
## 3                                                        
## 4                                                        
## 5                                                        
## 6                                                        
## 7  121.114036                                            
## 8  117.766213  26.564153                                 
## 9  117.707763   7.201256  22.418984                      
## 10 119.608791  30.092744  10.637359  26.116748           
## 11 119.365204  33.019096  15.336409  29.255482   5.952777
## 12 130.491702  41.856664  17.229544  37.778483  11.140371
## 13 125.047466  18.551858  43.715031  22.853050  45.370561
## 14 119.711970  17.822205  17.140450  15.116676  15.261002
## 15 124.815436  19.456110  38.576517  21.589961  44.248959
## 16 120.008119  37.645002  16.021790  34.347377  15.155177
## 17 119.635012  35.256225  12.522921  32.181857  13.699739
## 18 118.629445  37.805337  17.533849  34.070348  14.570844
## 19 118.831349  33.976175  16.986590  30.206181   7.697126
## 20 118.308513  35.461496  15.850232  31.733081  12.479645
##            11         12         13         14         15
## 2                                                        
## 3                                                        
## 4                                                        
## 5                                                        
## 6                                                        
## 7                                                        
## 8                                                        
## 9                                                        
## 10                                                       
## 11                                                       
## 12  10.180989                                            
## 13  46.940423  57.993866                                 
## 14  16.757819  25.552154  30.739429                      
## 15  48.524147  57.622278  27.204181  35.158036           
## 16  16.018137   9.916751  52.513357  25.306954  51.656812
## 17  15.475985  10.697363  51.133725  23.902339  48.735302
## 18  14.167245  10.455806  52.153491  25.115004  52.172107
## 19   2.178394  11.082962  47.508028  17.698579  49.659940
## 20  12.209922   9.846948  49.759392  22.485011  50.153277
##            16         17         18         19
## 2                                             
## 3                                             
## 4                                             
## 5                                             
## 6                                             
## 7                                             
## 8                                             
## 9                                             
## 10                                            
## 11                                            
## 12                                            
## 13                                            
## 14                                            
## 15                                            
## 16                                            
## 17   5.535921                                 
## 18   7.313364   9.643066                      
## 19  16.910716  16.591947  14.272415           
## 20   7.593005   9.322022   2.664249  12.413960

We can also get distances directly from runMatrixAnalysis(), this course’s matrix-analysis function, by specifying analysis = "dist". The argument output_format = "long" asks for the distances as a long table, one row per pair of lakes, rather than as the compact triangle dist() printed above. The long shape is the one we usually want, because it can be filtered, sorted and plotted like any other tidy table, and because it is exactly the shape a network’s edge list needs.

alaska_dist_long <- runMatrixAnalysis(
    data = alaska_lake_data_wide,
    analysis = c("dist"),
    output_format = "long",
    scale_variance = FALSE,
    columns_w_values_for_single_analyte = colnames(alaska_lake_data_wide)[3:15],
    columns_w_sample_ID_info = c("lake", "park")
)
## Replacing NAs in your data with mean

head(alaska_dist_long)
## # A tibble: 6 × 7
##   sample_unique_ID_sample_1 sample_unique_ID_samp…¹ distance
##   <chr>                     <chr>                      <dbl>
## 1 Devil_Mountain_Lake_BELA  Imuruk_Lake_BELA            17.2
## 2 Devil_Mountain_Lake_BELA  Kuzitrin_Lake_BELA          13.9
## 3 Devil_Mountain_Lake_BELA  Lava_Lake_BELA              18.7
## 4 Devil_Mountain_Lake_BELA  North_Killeak_Lake_BELA    363. 
## 5 Devil_Mountain_Lake_BELA  White_Fish_Lake_BELA       107. 
## 6 Devil_Mountain_Lake_BELA  Iniakuk_Lake_GAAR           39.9
## # ℹ abbreviated name: ¹​sample_unique_ID_sample_2
## # ℹ 4 more variables: lake_sample_1 <chr>,
## #   park_sample_1 <chr>, lake_sample_2 <chr>,
## #   park_sample_2 <chr>

Because the long table is tidy, finding the samples most like a given sample is just a filter and a sort. These are the lakes closest to Lake Narvakrak:

alaska_dist_long %>%
  filter(sample_unique_ID_sample_1 == "Lake_Narvakrak_NOAT") %>%
  arrange(distance) %>%
  head(3)
## # A tibble: 3 × 7
##   sample_unique_ID_sample_1 sample_unique_ID_samp…¹ distance
##   <chr>                     <chr>                      <dbl>
## 1 Lake_Narvakrak_NOAT       Nutavukti_Lake_GAAR         2.18
## 2 Lake_Narvakrak_NOAT       Lava_Lake_BELA              4.35
## 3 Lake_Narvakrak_NOAT       Lake_Selby_GAAR             7.70
## # ℹ abbreviated name: ¹​sample_unique_ID_sample_2
## # ℹ 4 more variables: lake_sample_1 <chr>,
## #   park_sample_1 <chr>, lake_sample_2 <chr>,
## #   park_sample_2 <chr>

Let’s look at all the distances at once. Why is North Killeak Lake so distant from everyone? Wasn’t that lake the one with such high chloride?

plot1 <- ggplot(
  alaska_dist_long,
  aes(x = sample_unique_ID_sample_1, y = sample_unique_ID_sample_2, fill = distance)
) +
  geom_tile() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))
plot1
Pairwise distances between Alaskan lakes, computed on unscaled data. A heat map in which each tile is one pair of lakes, with lake names on both axes and fill color showing the euclidean distance between that pair across all thirteen measured variables. Light tiles indicate dissimilar pairs. Because the variables were not scaled before the distances were computed, the pattern is dominated by the single most abundant analyte. Data are the 'alaska_lake_data' dataset used in UMD CHEM5725.

Pairwise distances between Alaskan lakes, computed on unscaled data. A heat map in which each tile is one pair of lakes, with lake names on both axes and fill color showing the euclidean distance between that pair across all thirteen measured variables. Light tiles indicate dissimilar pairs. Because the variables were not scaled before the distances were computed, the pattern is dominated by the single most abundant analyte. Data are the ‘alaska_lake_data’ dataset used in UMD CHEM5725.

concept check

Fill in the blank so that the distances come back as one row per pair of lakes. Press Run.

Self-check: in the long format, what does one row represent?


scaling

Back to North Killeak Lake. Let’s look at the averages and spreads of each analyte, and then at chloride specifically:

alaska_lake_data %>%
  group_by(element) %>%
  summarize(
    mean_mg_per_L = mean(mg_per_L),
    sd_mg_per_L = sd(mg_per_L)
  )
## # A tibble: 11 × 3
##    element mean_mg_per_L sd_mg_per_L
##    <chr>           <dbl>       <dbl>
##  1 Br            NA         NA      
##  2 C              4.66       2.84   
##  3 Ca            18.2       16.4    
##  4 Cl            23.1       77.5    
##  5 F             NA         NA      
##  6 K              1.01       1.87   
##  7 Mg             7.12       8.62   
##  8 N              0.0382     0.0567 
##  9 Na            12.6       37.1    
## 10 P              0.0006     0.00135
## 11 S              5.21       7.03
alaska_lake_data %>% filter(element == "Cl") %>%
  ggplot(aes(x = mg_per_L, y = lake)) + geom_col()
Chloride abundance across Alaskan lakes. A bar chart showing the concentration of chloride (in mg per L, x-axis) in each of twenty Alaskan lakes (lake names on y-axis). Each bar is a single measurement of chloride in a single lake. North Killeak Lake stands out, and because chloride is measured in much larger numbers than the other analytes, this one lake and this one analyte dominate the unscaled distances above. Data are from the 'alaska_lake_data' dataset used in UMD CHEM5725.

Chloride abundance across Alaskan lakes. A bar chart showing the concentration of chloride (in mg per L, x-axis) in each of twenty Alaskan lakes (lake names on y-axis). Each bar is a single measurement of chloride in a single lake. North Killeak Lake stands out, and because chloride is measured in much larger numbers than the other analytes, this one lake and this one analyte dominate the unscaled distances above. Data are from the ‘alaska_lake_data’ dataset used in UMD CHEM5725.

Chloride is far more abundant than all the other elements, and North Killeak Lake has very high chloride. This means that the distances we computed above are essentially controlled by chloride, not by all elements equally. What if we want equal control? The fix is to scale each variable before computing distances: subtract the column’s mean and divide by its standard deviation. Every column then has a mean of 0 and a standard deviation of 1 (these are sometimes called z-scores), so a difference of one standard deviation counts the same whether it is in chloride, phosphorus, pH or water temperature. Base R’s scale() does it like this:

alaska_scaled <- scale(alaska_lake_data_wide[,3:15])
head(round(alaska_scaled, 2))
##      water_temp    pH     C     N     P    Cl     S     F
## [1,]      -0.87  1.32 -0.44 -0.18 -0.44 -0.16 -0.65  0.80
## [2,]       1.29 -1.41  0.02 -0.44 -0.44 -0.28 -0.71  0.06
## [3,]      -0.55  0.79 -0.93 -0.67 -0.44 -0.29 -0.70 -0.31
## [4,]       1.85  0.73  1.28 -0.37  0.30 -0.27 -0.66  0.80
## [5,]       0.10  2.08 -0.12 -0.02  0.30  4.05 -0.74  3.38
## [6,]       0.24  1.60  2.69 -0.07  3.99  1.06 -0.72  1.16
##         Br    Na     K    Ca    Mg
## [1,] -0.22 -0.10  0.10 -0.76 -0.36
## [2,] -0.30 -0.30 -0.37 -1.05 -0.73
## [3,] -0.30 -0.31 -0.44 -1.00 -0.80
## [4,] -0.26 -0.26 -0.24 -0.39 -0.50
## [5,]  3.94  4.03  3.90  0.71  3.55
## [6,]  1.00  1.12  1.32  0.03  1.05

runMatrixAnalysis() scales for us with scale_variance = TRUE, which is its default. Importantly, scaling is a choice, not a requirement. If every column is in the same units and the large values genuinely matter more, then leaving the data unscaled is a defensible choice. Some distance metrics, which we will meet below, also need the raw values and must not be scaled at all. But whenever the columns are measurements in different units, scale first.

runMatrixAnalysis(
    data = alaska_lake_data_wide,
    analysis = c("dist"),
    output_format = "long",
    scale_variance = TRUE,
    columns_w_values_for_single_analyte = colnames(alaska_lake_data_wide)[3:15],
    columns_w_sample_ID_info = c("lake", "park")
) %>% ggplot(
  aes(x = sample_unique_ID_sample_1, y = sample_unique_ID_sample_2, fill = distance)
) +
  geom_tile() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1)) -> plot2
## Replacing NAs in your data with mean

plot_grid(plot1, plot2, ncol = 1, labels = c("A", "B"))
The effect of scaling on a distance matrix. Two heat maps of the same twenty Alaskan lakes, with lake names on both axes and fill color showing the distance between each pair. A) Distances computed on the raw measurements, which are controlled almost entirely by chloride because chloride is reported in far larger numbers than the other analytes. B) Distances computed after every variable was centered and scaled to unit variance, so that a one-standard-deviation difference counts the same in every analyte. Data are from the 'alaska_lake_data' dataset used in UMD CHEM5725.

The effect of scaling on a distance matrix. Two heat maps of the same twenty Alaskan lakes, with lake names on both axes and fill color showing the distance between each pair. A) Distances computed on the raw measurements, which are controlled almost entirely by chloride because chloride is reported in far larger numbers than the other analytes. B) Distances computed after every variable was centered and scaled to unit variance, so that a one-standard-deviation difference counts the same in every analyte. Data are from the ‘alaska_lake_data’ dataset used in UMD CHEM5725.

concept check

Fill in the blank with the command that centers each column and divides it by its standard deviation. Press Run.

Self-check: why does scaling change which lakes come out looking similar?


distance metrics

Everything so far has used euclidean distance, the straight-line distance between two points, extended to as many dimensions as there are variables. It is the default nearly everywhere, and for measurements in different units, once they are scaled, it is usually a sensible choice. But it carries assumptions: that every variable is a continuous number, that an absolute difference means the same thing anywhere on the scale, and that a value of zero is nothing special. When the data break those assumptions, euclidean distance can give a confident and wrong answer about which samples are alike. runMatrixAnalysis() offers several other metrics through its distance_method argument. Each one is a different definition of “similar”, and the sections below pair each with the kind of data it suits.

euclidean and manhattan

Euclidean distance squares each difference before adding them up (and then takes the square root of the total). Manhattan distance, also called city-block distance, simply adds up the absolute differences. The name comes from walking a city grid: the distance between two corners is the blocks walked east plus the blocks walked north, not the straight line through the buildings.

The squaring matters. Consider two pairs of samples. In the first pair, the samples differ by 1 unit in each of four variables. In the second, they differ by 4 units in one variable and not at all in the other three:

small_spread <- c(1, 1, 1, 1)
one_big      <- c(4, 0, 0, 0)

tibble(
  pair      = c("small_spread", "one_big"),
  euclidean = c(sqrt(sum(small_spread^2)), sqrt(sum(one_big^2))),
  manhattan = c(sum(abs(small_spread)),     sum(abs(one_big)))
)
## # A tibble: 2 × 3
##   pair         euclidean manhattan
##   <chr>            <dbl>     <dbl>
## 1 small_spread         2         4
## 2 one_big              4         4

Manhattan distance calls the two pairs equally different, since the total difference is 4 in both. Euclidean distance calls the second pair twice as far apart, because squaring rewards a single large difference over several small ones. Neither is wrong. But when one extreme value in one variable (a contaminated sample, an instrument spike) should not be allowed to decide which samples are alike, Manhattan distance is the more forgiving choice. It is requested with distance_method = "manhattan", and it should be scaled for the same reasons euclidean distance is.

concept check

Fill in the blank to compute the Manhattan distance between these two samples. Press Run.

Self-check: which metric is pulled harder by a single very large difference in one variable?


correlation and cosine

Sometimes two samples have the same pattern across the variables but at different overall sizes. A diluted extract and a concentrated one, a small leaf and a large one, a long burn and a short one: the profile is the same, but every number in one sample is larger. Euclidean and Manhattan distances treat that size difference as dissimilarity. If the question is about the pattern, we want a metric that ignores size.

Correlation distance is 1 minus the Pearson correlation between two samples across all the variables (distance_method = "correlation"). Two samples whose values rise and fall together have a correlation near 1 and so a distance near 0, however different their totals are. Cosine distance (distance_method = "cosine") treats each sample as an arrow pointing out from the origin and measures the angle between two arrows: arrows pointing the same way are at distance 0 no matter how long they are. The two are close cousins. The difference is that correlation first subtracts each sample’s own mean, while cosine does not. Cosine is the standard choice for comparing the embeddings we will meet in the language model chapters, where each sample is described by hundreds of numbers and the direction of the arrow carries the meaning.

The wood_smoke data set holds the abundances of 254 compounds in smoke from six tree species, three hardwoods and three softwoods. The species produce different total amounts of smoke compounds:

wood_smoke %>%
  group_by(wood_type, species) %>%
  summarize(total_mg_g = sum(abundance_mg_g), .groups = "drop") %>%
  arrange(total_mg_g)
## # A tibble: 6 × 3
##   wood_type species            total_mg_g
##   <chr>     <chr>                   <dbl>
## 1 softwood  eastern_white_pine       209.
## 2 hardwood  red_maple                240.
## 3 softwood  eastern_hemlock          293.
## 4 hardwood  paper_birch              346.
## 5 softwood  balsam_fir               379.
## 6 hardwood  northern_red_oak         424.

Let’s ask which species is most like red maple, first by euclidean distance and then by correlation distance:

wood_smoke %>%
  select(wood_type, species, compound, abundance_mg_g) %>%
  pivot_wider(names_from = "compound", values_from = "abundance_mg_g") -> wood_smoke_wide

closest_to_red_maple <- function(method) {
  runMatrixAnalysis(
    data = wood_smoke_wide,
    analysis = "dist",
    distance_method = method,
    scale_variance = FALSE,
    output_format = "long",
    columns_w_values_for_single_analyte = colnames(wood_smoke_wide)[-(1:2)],
    columns_w_sample_ID_info = c("species", "wood_type")
  ) %>%
    filter(sample_unique_ID_sample_1 == "red_maple_hardwood") %>%
    arrange(distance) %>%
    head(2)
}

closest_to_red_maple("euclidean")
## # A tibble: 2 × 7
##   sample_unique_ID_sample_1 sample_unique_ID_samp…¹ distance
##   <chr>                     <chr>                      <dbl>
## 1 red_maple_hardwood        eastern_hemlock_softwo…     56.4
## 2 red_maple_hardwood        paper_birch_hardwood        61.6
## # ℹ abbreviated name: ¹​sample_unique_ID_sample_2
## # ℹ 4 more variables: species_sample_1 <chr>,
## #   wood_type_sample_1 <chr>, species_sample_2 <chr>,
## #   wood_type_sample_2 <chr>
closest_to_red_maple("correlation")
## # A tibble: 2 × 7
##   sample_unique_ID_sample_1 sample_unique_ID_samp…¹ distance
##   <chr>                     <chr>                      <dbl>
## 1 red_maple_hardwood        northern_red_oak_hardw…   0.0177
## 2 red_maple_hardwood        eastern_hemlock_softwo…   0.125 
## # ℹ abbreviated name: ¹​sample_unique_ID_sample_2
## # ℹ 4 more variables: species_sample_1 <chr>,
## #   wood_type_sample_1 <chr>, species_sample_2 <chr>,
## #   wood_type_sample_2 <chr>

By euclidean distance, red maple’s nearest neighbor is eastern hemlock, a softwood whose main similarity to red maple is that it produces a similar total amount of smoke compounds. By correlation distance, the nearest neighbor is northern red oak, a fellow hardwood with a similar profile at a different overall level. Here, scaling is off on purpose: every column is in the same units (mg of compound per g of wood), and z-scaling each compound would let the hundreds of trace compounds count as much as the major ones.

concept check

Fill in the blank with the metric that compares the pattern of compounds rather than the amounts, and see which species comes out closest to paper birch. Press Run.

Self-check: sample B is exactly sample A with every value doubled. What is the correlation distance between them?


abundance and composition data

A great deal of chemical and biological data records how much of each component a sample contains: compound abundances from GC-MS, percentages of each compound class in a wax, counts of each species in a sample. Two things make this kind of data awkward for euclidean distance.

The first is zeros. In abundance data, a zero usually means “not present”, and many samples share many zeros. Euclidean distance can produce a strange result here, sometimes called the abundance paradox. Consider three sites and three species:

paradox <- tibble(
  site      = c("site_1", "site_2", "site_3"),
  species_A = c(0, 1, 0),
  species_B = c(1, 0, 4),
  species_C = c(1, 0, 4)
)
paradox
## # A tibble: 3 × 4
##   site   species_A species_B species_C
##   <chr>      <dbl>     <dbl>     <dbl>
## 1 site_1         0         1         1
## 2 site_2         1         0         0
## 3 site_3         0         4         4

Sites 1 and 3 contain exactly the same species. Site 3 just has more of each. Site 2 shares no species at all with site 1. Yet euclidean distance says site 1 is closer to site 2:

runMatrixAnalysis(
    data = paradox,
    analysis = "dist",
    distance_method = "euclidean",
    scale_variance = FALSE,
    output_format = "long",
    columns_w_values_for_single_analyte = c("species_A", "species_B", "species_C"),
    columns_w_sample_ID_info = "site"
) %>% filter(sample_unique_ID_sample_1 == "site_1")
## # A tibble: 2 × 3
##   sample_unique_ID_sample_1 sample_unique_ID_samp…¹ distance
##   <chr>                     <chr>                      <dbl>
## 1 site_1                    site_2                      1.73
## 2 site_1                    site_3                      4.24
## # ℹ abbreviated name: ¹​sample_unique_ID_sample_2

Bray-Curtis dissimilarity (distance_method = "bray_curtis") was built for this kind of data. It adds up the absolute differences between two samples and divides by the total amount in both, so it runs from 0 (identical) to 1 (nothing in common). A component absent from both samples adds nothing to either part of that fraction, so shared absences never make two samples look alike. On the same three sites, it puts site 1 with site 3, where it belongs:

runMatrixAnalysis(
    data = paradox,
    analysis = "dist",
    distance_method = "bray_curtis",
    scale_variance = FALSE,
    output_format = "long",
    columns_w_values_for_single_analyte = c("species_A", "species_B", "species_C"),
    columns_w_sample_ID_info = "site"
) %>% filter(sample_unique_ID_sample_1 == "site_1")
## # A tibble: 2 × 3
##   sample_unique_ID_sample_1 sample_unique_ID_samp…¹ distance
##   <chr>                     <chr>                      <dbl>
## 1 site_1                    site_2                       1  
## 2 site_1                    site_3                       0.6
## # ℹ abbreviated name: ¹​sample_unique_ID_sample_2

When each sample’s values are percentages that add up to 100, Bray-Curtis has a particularly plain reading: it is the fraction of the composition that the two samples do not have in common. The chemical_blooms data set records the percentage of each compound class in the leaf waxes of 78 plant species, and nearly three quarters of its entries are zero. Let’s compare two members of the mint family, Monarda fistulosa and Salvia apiana, with a stonecrop, Crassula perfoliata:

mint_comparison <- chemical_blooms %>%
  filter(label %in% c("Monarda_fistulosa", "Salvia_apiana", "Crassula_perfoliata"))
mint_comparison
## # A tibble: 3 × 10
##   label     Alkanes Sec_Alcohols Others Fatty_acids Alcohols
##   <chr>       <dbl>        <dbl>  <dbl>       <dbl>    <dbl>
## 1 Monarda_…    0               0      0        12.5     37.6
## 2 Salvia_a…    0               0      0        16.4     10.7
## 3 Crassula…    9.81            0      0        22.8     30.9
## # ℹ 4 more variables: Triterpenoids <dbl>, Ketones <dbl>,
## #   Other_compounds <dbl>, Aldehydes <dbl>

compare_waxes <- function(method) {
  runMatrixAnalysis(
    data = mint_comparison,
    analysis = "dist",
    distance_method = method,
    scale_variance = FALSE,
    output_format = "long",
    columns_w_values_for_single_analyte = colnames(chemical_blooms)[-1],
    columns_w_sample_ID_info = "label"
  ) %>% filter(sample_unique_ID_sample_1 == "Monarda_fistulosa")
}

compare_waxes("euclidean")
## # A tibble: 2 × 3
##   sample_unique_ID_sample_1 sample_unique_ID_samp…¹ distance
##   <chr>                     <chr>                      <dbl>
## 1 Monarda_fistulosa         Salvia_apiana               40.6
## 2 Monarda_fistulosa         Crassula_perfoliata         39.9
## # ℹ abbreviated name: ¹​sample_unique_ID_sample_2
compare_waxes("bray_curtis")
## # A tibble: 2 × 3
##   sample_unique_ID_sample_1 sample_unique_ID_samp…¹ distance
##   <chr>                     <chr>                      <dbl>
## 1 Monarda_fistulosa         Salvia_apiana              0.334
## 2 Monarda_fistulosa         Crassula_perfoliata        0.404
## # ℹ abbreviated name: ¹​sample_unique_ID_sample_2

Euclidean distance calls Monarda very slightly closer to the stonecrop than to the other mint, largely because the two mints differ by about 30 percentage points in triterpenoids, and squaring makes that one large difference count heavily. Bray-Curtis puts the two mints together: they share about two thirds of their wax composition, whereas Monarda and the stonecrop share about 60%.

The second awkward feature is that percentages are not independent. If one component goes up, the others must go down, because the total is fixed at 100. Data with this property are called compositional, and the principled way to compare them is the Aitchison distance (distance_method = "aitchison"). It replaces each value with the logarithm of its ratio to the sample’s geometric mean, then takes the euclidean distance between the results. Working with ratios means a component going from 1% to 2% counts as much as one going from 20% to 40%: both doubled. And it means two samples in the same proportions are identical, whatever their totals. The catch is that the logarithm of zero is undefined. runMatrixAnalysis() replaces zeros with half the smallest positive value and says so, but in data as zero-heavy as chemical_blooms those stand-in values end up driving the result. Aitchison distance is the right tool for compositions in which most components are present in most samples (the major fatty acids of a set of seed oils, for example). For zero-heavy composition data, Bray-Curtis is the safer choice.

None of these three metrics can be used on scaled data. Bray-Curtis, Aitchison and the Jaccard distance below all need the raw, non-negative values, and scaling centers every column on zero, which produces negative numbers. runMatrixAnalysis() stops with a message if they are combined with scaling, so each of them needs scale_variance = FALSE.

concept check

Fill in the two blanks so that the paradox sites are compared with Bray-Curtis dissimilarity. Press Run, then try changing the second blank to TRUE to see what happens.

Self-check: why must scaling be turned off for Bray-Curtis?


presence and absence

Sometimes the amounts are not trustworthy or not interesting, and the only question is whether each thing is there: which compounds were detected, which genes are present in a genome, which species were seen at a site. For this, the Jaccard distance (distance_method = "jaccard") treats every value above zero as “present”, then compares the two sets: it is the number of things present in only one of the two samples, divided by the number present in either. Things absent from both are ignored, just as in Bray-Curtis. Here is the Jaccard distance between every pair of wood smoke samples:

runMatrixAnalysis(
    data = wood_smoke_wide,
    analysis = "dist",
    distance_method = "jaccard",
    scale_variance = FALSE,
    columns_w_values_for_single_analyte = colnames(wood_smoke_wide)[-(1:2)],
    columns_w_sample_ID_info = "species"
)
##                    red_maple northern_red_oak paper_birch
## northern_red_oak   0.4230769                             
## paper_birch        0.3902439        0.2602041            
## eastern_white_pine 0.5177665        0.3956522   0.3679245
## eastern_hemlock    0.5226131        0.3584071   0.4394619
## balsam_fir         0.5023923        0.2969432   0.3616071
##                    eastern_white_pine eastern_hemlock
## northern_red_oak                                     
## paper_birch                                          
## eastern_white_pine                                   
## eastern_hemlock             0.2009569                
## balsam_fir                  0.2286996       0.2117117

The three softwoods are all within about 0.23 of one another: on which compounds their smoke contains at all, they are nearly interchangeable, and each of them is further from every hardwood than from the other two softwoods. Note that this is the default "wide" output format, a triangle like the one dist() printed at the start of the chapter.

concept check

Fill in the blank to compare the wood smoke samples on which compounds are present, ignoring how much of each there is. Press Run.

Self-check: two smoke samples contain exactly the same compounds, but every compound is ten times more abundant in one of them. What is their Jaccard distance?


mixed data types

Every metric so far needs numbers. Real data sets often mix numbers with categories: a habitat, a country of origin, a growth form, a yes or no. Gower distance (distance_method = "gower") handles the mixture. For each variable it computes a difference between 0 and 1: for a number, the absolute difference divided by that variable’s range, and for a category, 0 if the two samples match and 1 if they do not. The Gower distance is the average of those differences. Because each numeric variable is divided by its own range, Gower distance does its own scaling, so runMatrixAnalysis() leaves scale_variance out of it.

The hops_components data set describes 38 hop varieties by their oil and acid chemistry and by two categories: country of origin and brewing use (aroma, bittering or dry hopping). Let’s find the varieties closest to Chinook, first on chemistry alone and then on chemistry plus the two categories:

hop_chemistry <- c(
  "total_oil", "alpha_acids", "beta_acids", "humulene",
  "myrcene", "humulone", "caryophyllene", "farnesene"
)

closest_to_chinook <- function(columns) {
  runMatrixAnalysis(
    data = hops_components,
    analysis = "dist",
    distance_method = "gower",
    output_format = "long",
    columns_w_values_for_single_analyte = columns,
    columns_w_sample_ID_info = "hop_variety"
  ) %>%
    filter(sample_unique_ID_sample_1 == "Chinook") %>%
    arrange(distance) %>%
    head(3)
}

closest_to_chinook(hop_chemistry)
## # A tibble: 3 × 3
##   sample_unique_ID_sample_1 sample_unique_ID_samp…¹ distance
##   <chr>                     <chr>                      <dbl>
## 1 Chinook                   Nugget                    0.0950
## 2 Chinook                   Millennium                0.104 
## 3 Chinook                   Herkules                  0.116 
## # ℹ abbreviated name: ¹​sample_unique_ID_sample_2
closest_to_chinook(c("hop_origin", "hop_brewing_usage", hop_chemistry))
## # A tibble: 3 × 3
##   sample_unique_ID_sample_1 sample_unique_ID_samp…¹ distance
##   <chr>                     <chr>                      <dbl>
## 1 Chinook                   Centennial                 0.118
## 2 Chinook                   Nugget                     0.176
## 3 Chinook                   Millennium                 0.183
## # ℹ abbreviated name: ¹​sample_unique_ID_sample_2

On chemistry alone, Chinook’s nearest neighbor is Nugget. Once origin and brewing use count, it is Centennial, which like Chinook is an American variety used for dry hopping. Whether the categories should count is a decision about the question being asked, not something the metric can settle.

concept check

Fill in the blank with the metric that can compare hop varieties on a mix of numbers and categories. Press Run.

Self-check: how does Gower distance score a categorical variable such as country of origin?



chapter summary

Download this summary as a one-page PDF

Choosing a distance metric. This decision flowchart starts from what is in the columns of a data set and ends at a recommended setting for the distance_method argument of runMatrixAnalysis, together with the matching scale_variance setting. Yellow boxes are questions about the data and green boxes are recommended metrics.

Choosing a distance metric. This decision flowchart starts from what is in the columns of a data set and ends at a recommended setting for the distance_method argument of runMatrixAnalysis, together with the matching scale_variance setting. Yellow boxes are questions about the data and green boxes are recommended metrics.

Two decisions go into every distance matrix. Scaling decides which variables count: scale measurements that are in different units so that no single variable dominates, and leave raw amounts unscaled when you want distances to reflect potential differences in the scales that your variables occupy. The distance metric decides what “similar” means: the same size and values, the same pattern, the same composition, the same things present, or the same mix of measurements and categories. The flowchart and the table below summarize the choices.

distance_method Similar means… Typical data Scale first?
"euclidean" close values in every variable, with large single differences counting heavily measurements in different units (pH, mg/L, temperature) yes, when units differ
"manhattan" close values in every variable, with one extreme difference counting less as euclidean, when outliers or spikes are a concern yes, when units differ
"correlation" the same pattern across the variables, regardless of size profiles at different concentrations or totals optional
"cosine" pointing in the same direction, regardless of length embeddings and other high-dimensional data optional
"bray_curtis" sharing the same amounts of the same components abundances and percentages, especially with many zeros no
"aitchison" the same proportions between components compositions summing to 100% with few zeros no
"jaccard" containing the same things, amounts ignored presence/absence, detected or not no
"gower" matching across a mix of numbers and categories tables with categorical columns handled internally

further reading