Erie Data: Unconstrained Ordination

Introduction

Multivariable data with a large number of variables can be difficult to interpret. In order to understand relationships in the data we need to summarize the variability in a way that reduces the dimensionality of the data. Ordination attempts to order the samples/observations in a data set along a small number of axes so that similar samples are close to each other and dissimilar samples are far from each other.

There are several different ordination methods that have been applied to ecological data in the past. Ordination methods fall into two categories: constrained ordination and unconstrained ordination. Both groups of methods start with an abundance matrix with samples along one dimension (e.g., rows) and species (or other taxonomic classification) along the the other (e.g., columns). Two samples are similar if they have similar species compositions. Unconstrained ordination uses information based on species composition to position samples along the ordination axes. Constrained ordination also uses environmental data, and attempts to reveal whether species composition is associated with the environmental variables.

In this tutorial we will explore the Lake Erie bacterial community data using two unconstrained ordination methods, Principal Coordinate Analysis (PCoA) and Nonmetric Multidimensional Scaling (NMDS). Both of these methods are considered distance-based methods because they are both based on a distance matrix that is computed from the species abundance matrix. The distance between two samples can be calculated in a variety of ways. For example, the Euclidean distance can be computed between the abundance vectors for two samples. The distance matrix has the samples along both dimensions (rows and columns), so that the element in the \(i\)th row and the \(j\)th column is the distance between sample \(i\) and sample \(j\).

The number of dimensions of the abundance data is equal to the number of species. For ease of visualization we would like to reduce the dimensionality of the data so that it can be represented in two or (less often) three dimensions. However, we would like to do this in a way that preserves as much of the original patterns of variation as possible. PCoA rotates the higher dimensional data cloud and projects it into lower dimension in such a way as to maximize the linear correlations between the distances in the lower dimensional representation and the distances in the original data. NMDS positions the samples in the lower dimensional space while attempting to maximize the agreement between the rank order of distances in the lower dimensional representation and the rank order of distances in the original data. The stress measures the disagreement between the two rank orders.

There are a variety of ways to perform ordination analyses in R. Many of the ordination techniques used in ecology have been implemented in the package vegan. We will use this package for caclulating distance matrices and for NMDS. We will use the ape package for PCoA.

Your Quarto document

Create a new Quarto document for this tutorial, give it an informative name, and save it in the code subdirectory of your watershedErie directory. As you work through this tutorial, transcribe the code into your own document and run it. Make sure you also write about what you are doing so that you can understand your work when you read it later.

Setup and reading in the clean data

Let’s start by cleaning up our work space and loading the required packages. If you have not installed the vegan and ape packages, do so now.

rm(list = ls())
gc()
          used (Mb) gc trigger (Mb) limit (Mb) max used (Mb)
Ncells  579876 31.0    1322067 70.7         NA   669442 35.8
Vcells 1067917  8.2    8388608 64.0     102400  1851737 14.2
library(tidyverse)
── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.1.3     ✔ readr     2.1.4
✔ forcats   1.0.0     ✔ stringr   1.5.0
✔ ggplot2   3.4.3     ✔ tibble    3.2.1
✔ lubridate 1.9.2     ✔ tidyr     1.3.0
✔ purrr     1.0.2     
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag()    masks stats::lag()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(vegan)
Loading required package: permute
Loading required package: lattice
This is vegan 2.6-4
library(ape)

Attaching package: 'ape'

The following object is masked from 'package:dplyr':

    where

Import the data

Let’s load the filtered data. Note that this is not the aggregated data set that we used in the last tutorial.

erie <- readRDS("clean_data/erie.rds")
erie_tax <- readRDS("clean_data/erie_tax.rds")
erie_meta <- readRDS("clean_data/erie_meta.rds")

Remove samples with a small number of reads (<10k)

We will remove any samples with a sequencing depth of less than 10,000 reads. Since we added the sequencing depth to the metadata this is easy to do.

erie_meta <- erie_meta %>% filter(sequencingDepth >= 10000)
erie_samples <- erie_meta %>% pull(SampleID)
erie <- erie %>% filter(SampleID %>% is.element(erie_samples))

Computing Relative Abundances and Filtering the Data

We will normalize each sample by dividing by by the total number of sequencing reads. This is similar to how we computed relative abundances in Tutorial 5. This time, however, we are not aggregating the data first, so the results will be the relative abundances for the OTUs in each sample rather than the relative abundances for the phyla.

Start by lengthening the data and grouping by SampleID.

erie <- erie %>%
  pivot_longer(cols = starts_with("Otu"),
               names_to = "OTU",
               values_to = "Abundance") %>%
  group_by(SampleID)

Scale the data to get relative abundances, and remove rows with relative abundance below 0.005%.

erie <- erie %>%
  mutate(Rel_Abundance = Abundance/sum(Abundance)) %>%
  filter(Rel_Abundance > 5e-5)
erie
# A tibble: 27,902 × 6
# Groups:   SampleID [56]
   label SampleID  numOtus OTU      Abundance Rel_Abundance
   <dbl> <chr>       <int> <chr>        <int>         <dbl>
 1  0.03 E0010_CNA   19656 Otu00002      1271      0.0445  
 2  0.03 E0010_CNA   19656 Otu00004      3264      0.114   
 3  0.03 E0010_CNA   19656 Otu00005         3      0.000105
 4  0.03 E0010_CNA   19656 Otu00006        41      0.00143 
 5  0.03 E0010_CNA   19656 Otu00007       110      0.00385 
 6  0.03 E0010_CNA   19656 Otu00008      1351      0.0473  
 7  0.03 E0010_CNA   19656 Otu00010      1956      0.0684  
 8  0.03 E0010_CNA   19656 Otu00011      3162      0.111   
 9  0.03 E0010_CNA   19656 Otu00012        48      0.00168 
10  0.03 E0010_CNA   19656 Otu00013       139      0.00486 
# ℹ 27,892 more rows

We probably dropped some OTUs from the data altogether. Even though we won’t use the tax table here, let’s filter it down to the OTUs we kept. We’ll use this opportunity to see how many OTUs we have lost by counting rows in erie_tax tibble before and after dropping the OTUs.

We will use a filtering join (semi_join) to filter the OTUs out of erie_tax that are no longer in erie.

nrow(erie_tax)
[1] 7522
erie_tax <- erie_tax %>% semi_join(erie, by = "OTU") #filtering join
nrow(erie_tax)
[1] 4862

Now we pivot back to wide format (one row per sample). The command will insert zeros when there are missing values (OTUs that were filtered out for some samples but not others). The OTUs that we dropped from all samples will not be in the resulting data frame. We drop the abundances and proceed with only the relative abundances.

erie <- erie %>%
  ungroup %>%
  select(-c(Abundance)) %>%
  pivot_wider(names_from = OTU, values_from = Rel_Abundance, values_fill = 0)
erie
# A tibble: 56 × 4,865
   label SampleID  numOtus Otu00002 Otu00004 Otu00005 Otu00006 Otu00007 Otu00008
   <dbl> <chr>       <int>    <dbl>    <dbl>    <dbl>    <dbl>    <dbl>    <dbl>
 1  0.03 E0010_CNA   19656   0.0445   0.114  0.000105 0.00143   0.00385   0.0473
 2  0.03 E0012_CNA   19656   0.0280   0.0948 0.000375 0.00296   0.0325    0.0476
 3  0.03 E0014_CNA   19656   0.0578   0.115  0.000199 0.000598  0.0113    0.0315
 4  0.03 E0016_CNA   19656   0.0525   0.0877 0        0.00943   0.0427    0.0328
 5  0.03 E0018_CNA   19656   0.0454   0.0942 0        0.00925   0.0596    0.0465
 6  0.03 E0020_CNA   19656   0.0439   0.101  0.00411  0.0154    0.0407    0.0518
 7  0.03 E0022_CNA   19656   0.0455   0.104  0.00204  0.0216    0.0344    0.0482
 8  0.03 E0024_CNA   19656   0.0401   0.0772 0.000243 0.0224    0.0318    0.0445
 9  0.03 E0026_CNA   19656   0.0449   0.0905 0.00258  0.0280    0.0145    0.0379
10  0.03 E0028_CNA   19656   0.0340   0.0766 0.0164   0.0696    0.0335    0.0286
# ℹ 46 more rows
# ℹ 4,856 more variables: Otu00010 <dbl>, Otu00011 <dbl>, Otu00012 <dbl>,
#   Otu00013 <dbl>, Otu00015 <dbl>, Otu00016 <dbl>, Otu00017 <dbl>,
#   Otu00018 <dbl>, Otu00019 <dbl>, Otu00023 <dbl>, Otu00025 <dbl>,
#   Otu00026 <dbl>, Otu00027 <dbl>, Otu00028 <dbl>, Otu00029 <dbl>,
#   Otu00031 <dbl>, Otu00033 <dbl>, Otu00034 <dbl>, Otu00035 <dbl>,
#   Otu00036 <dbl>, Otu00038 <dbl>, Otu00039 <dbl>, Otu00040 <dbl>, …

Community matrix

To do the ordination, we need to create a community matrix with rows corresponding to samples and columns corresponding to OTUs. Our erie tibble already has this shape, but it has some extra columns (label and numOtus), and it is a tibble object instead of a matrix. A matrix is a simpler data structure. There are several ways in which it is different from a tibble. One is that all of the data must be of the same type in a matrix. We can give the matrix row and column names, so we name the rows using the sample IDs, and we name the columns according to the OTUs. All of the entries in the matrix are just relative abundances (numbers). The row and column names help us to associate the numbers with the correnct samples and OTUs.

erie_rownames <- erie %>% pull(SampleID)
erie_matrix <- erie %>%
  select(starts_with("Otu")) %>%
  as.matrix() %>%
  `rownames<-`(erie_rownames)
erie_matrix %>% dim
[1]   56 4862

PCOA

We can use the ape::pcoa function to perform pcoa, but we need to compute a dissimilarity/distance matrix first. We can use the vegdist command in the vegan package to do that. The vegdist command takes a community matrix and a method as inputs. We will use the “bray” method, which computes Bray-Curtis dissimilarities.

erie_dist <- erie_matrix %>%
  vegdist(method = "bray")

Let’s save the distance matrix in case we need to use it again.

erie_dist %>% saveRDS("clean_data/erie_dist_matrix_bray.rds")

Now, performing the PCoA is straightforward. We just use the pcoa command from the ape package.

erie_pcoa <- erie_dist %>% pcoa()

We will plot our data using the first two principal coordinates. The following code creates a tibble with the coordinates of each sample along the two first two PCoA axes.

erie_pcoa_tib <- erie_pcoa %>%
  pluck("vectors") %>%
  .[,1:2] %>% 
  as_tibble(rownames = "SampleID") %>%
  rename( pcoa.1 = Axis.1, pcoa.2 = Axis.2)
erie_pcoa_tib
# A tibble: 56 × 3
   SampleID  pcoa.1   pcoa.2
   <chr>      <dbl>    <dbl>
 1 E0010_CNA -0.336  0.00137
 2 E0012_CNA -0.276 -0.0670 
 3 E0014_CNA -0.334 -0.00544
 4 E0016_CNA -0.271 -0.108  
 5 E0018_CNA -0.247 -0.133  
 6 E0020_CNA -0.175 -0.165  
 7 E0022_CNA -0.236 -0.109  
 8 E0024_CNA -0.169 -0.180  
 9 E0026_CNA -0.191 -0.136  
10 E0028_CNA -0.133 -0.135  
# ℹ 46 more rows

We will join this tibble with the Erie metadata, so that we can use variables in the metadata in the PCoA plot.

erie_meta <- erie_meta %>%
  left_join(erie_pcoa_tib)
Joining with `by = join_by(SampleID)`
erie_meta
# A tibble: 56 × 35
   SampleID  Date       Station   Shore Fraction Type  Samplenum Date_year Month
   <chr>     <date>     <chr>     <chr> <chr>    <chr> <chr>     <chr>     <chr>
 1 E0010_CNA 2014-06-16 nearshor… near… CNA      samp… E0010     6/16/14   June 
 2 E0012_CNA 2014-06-16 nearshor… near… CNA      samp… E0012     6/16/14   June 
 3 E0014_CNA 2014-06-16 offshore  offs… CNA      samp… E0014     6/16/14   June 
 4 E0016_CNA 2014-06-30 nearshor… near… CNA      samp… E0016     6/30/14   June 
 5 E0018_CNA 2014-06-30 nearshor… near… CNA      samp… E0018     6/30/14   June 
 6 E0020_CNA 2014-06-30 offshore  offs… CNA      samp… E0020     6/30/14   June 
 7 E0022_CNA 2014-07-08 nearshor… near… CNA      samp… E0022     7/8/14    July 
 8 E0024_CNA 2014-07-08 nearshor… near… CNA      samp… E0024     7/8/14    July 
 9 E0026_CNA 2014-07-08 offshore  offs… CNA      samp… E0026     7/8/14    July 
10 E0028_CNA 2014-07-14 nearshor… near… CNA      samp… E0028     7/14/14   July 
# ℹ 46 more rows
# ℹ 26 more variables: Days <dbl>, pH <dbl>, SpCond <dbl>, StationDepth <dbl>,
#   Secchi <dbl>, Temp <dbl>, Turbidity <dbl>, ParMC <dbl>, DissMC <dbl>,
#   Phycocyanin <dbl>, Chla <dbl>, DOC <dbl>, SRP <dbl>, TP <dbl>, TDP <dbl>,
#   POC <dbl>, PON <dbl>, PP <dbl>, N.P <dbl>, Nitrate <dbl>, Ammonia <dbl>,
#   H2O2 <dbl>, CDOM <dbl>, sequencingDepth <int>, pcoa.1 <dbl>, pcoa.2 <dbl>

NMDS

We will use the metaMDS function in vegan to perform the NMDS ordination. We can use the same dissimilarity matrix we computed earlier.

set.seed(8675309) #seed the rng so that we all get the same results
erie_nmds <- erie_dist %>% metaMDS(trymax = 200, sfgrmin = 1e-9, sratmax = 0.999999999)
Run 0 stress 0.1288091 
Run 1 stress 0.1310707 
Run 2 stress 0.1340905 
Run 3 stress 0.129845 
Run 4 stress 0.1507764 
Run 5 stress 0.1402772 
Run 6 stress 0.1297044 
Run 7 stress 0.1460872 
Run 8 stress 0.1483734 
Run 9 stress 0.160422 
Run 10 stress 0.1521334 
Run 11 stress 0.1377881 
Run 12 stress 0.1302375 
Run 13 stress 0.1507874 
Run 14 stress 0.160927 
Run 15 stress 0.1449993 
Run 16 stress 0.1297434 
Run 17 stress 0.1360442 
Run 18 stress 0.1584935 
Run 19 stress 0.1301502 
Run 20 stress 0.1290188 
... Procrustes: rmse 0.01966698  max resid 0.1075564 
Run 21 stress 0.1558269 
Run 22 stress 0.1275425 
... New best solution
... Procrustes: rmse 0.03374706  max resid 0.1920336 
Run 23 stress 0.1433967 
Run 24 stress 0.1364208 
Run 25 stress 0.1310707 
Run 26 stress 0.1295761 
Run 27 stress 0.130164 
Run 28 stress 0.1297434 
Run 29 stress 0.1359667 
Run 30 stress 0.1297956 
Run 31 stress 0.1377881 
Run 32 stress 0.1302375 
Run 33 stress 0.1322382 
Run 34 stress 0.1278569 
... Procrustes: rmse 0.01864103  max resid 0.1002752 
Run 35 stress 0.1598183 
Run 36 stress 0.1305527 
Run 37 stress 0.1367497 
Run 38 stress 0.1362864 
Run 39 stress 0.1340919 
Run 40 stress 0.1342174 
Run 41 stress 0.1376068 
Run 42 stress 0.1434103 
Run 43 stress 0.1515569 
Run 44 stress 0.154211 
Run 45 stress 0.131142 
Run 46 stress 0.1275425 
... New best solution
... Procrustes: rmse 8.181112e-08  max resid 3.464119e-07 
... Similar to previous best
*** Best solution repeated 1 times
erie_nmds

Call:
metaMDS(comm = ., trymax = 200, sfgrmin = 1e-09, sratmax = 0.999999999) 

global Multidimensional Scaling using monoMDS

Data:     . 
Distance: bray 

Dimensions: 2 
Stress:     0.1275425 
Stress type 1, weak ties
Best solution was repeated 1 time in 46 tries
The best solution was from try 46 (random start)
Scaling: centring, PC rotation, halfchange scaling 
Species: scores missing

The stress is 0.128, which is considered acceptable since it is less than 0.2.

Let’s create a tibble with the NMDS coordinates.

erie_nmds_tib <- erie_nmds %>% 
  pluck("points") %>%
  as_tibble(rownames = "SampleID") %>%
  rename(nmds.1 = MDS1, nmds.2 = MDS2)
erie_nmds_tib
# A tibble: 56 × 3
   SampleID  nmds.1  nmds.2
   <chr>      <dbl>   <dbl>
 1 E0010_CNA -0.664 -0.113 
 2 E0012_CNA -0.568 -0.201 
 3 E0014_CNA -0.558 -0.0642
 4 E0016_CNA -0.495 -0.193 
 5 E0018_CNA -0.414 -0.190 
 6 E0020_CNA -0.337 -0.295 
 7 E0022_CNA -0.380 -0.132 
 8 E0024_CNA -0.294 -0.302 
 9 E0026_CNA -0.302 -0.152 
10 E0028_CNA -0.173 -0.123 
# ℹ 46 more rows

Then, we join this into the metadata, too.

erie_meta <- erie_meta %>%
  left_join(erie_nmds_tib)
Joining with `by = join_by(SampleID)`
erie_meta
# A tibble: 56 × 37
   SampleID  Date       Station   Shore Fraction Type  Samplenum Date_year Month
   <chr>     <date>     <chr>     <chr> <chr>    <chr> <chr>     <chr>     <chr>
 1 E0010_CNA 2014-06-16 nearshor… near… CNA      samp… E0010     6/16/14   June 
 2 E0012_CNA 2014-06-16 nearshor… near… CNA      samp… E0012     6/16/14   June 
 3 E0014_CNA 2014-06-16 offshore  offs… CNA      samp… E0014     6/16/14   June 
 4 E0016_CNA 2014-06-30 nearshor… near… CNA      samp… E0016     6/30/14   June 
 5 E0018_CNA 2014-06-30 nearshor… near… CNA      samp… E0018     6/30/14   June 
 6 E0020_CNA 2014-06-30 offshore  offs… CNA      samp… E0020     6/30/14   June 
 7 E0022_CNA 2014-07-08 nearshor… near… CNA      samp… E0022     7/8/14    July 
 8 E0024_CNA 2014-07-08 nearshor… near… CNA      samp… E0024     7/8/14    July 
 9 E0026_CNA 2014-07-08 offshore  offs… CNA      samp… E0026     7/8/14    July 
10 E0028_CNA 2014-07-14 nearshor… near… CNA      samp… E0028     7/14/14   July 
# ℹ 46 more rows
# ℹ 28 more variables: Days <dbl>, pH <dbl>, SpCond <dbl>, StationDepth <dbl>,
#   Secchi <dbl>, Temp <dbl>, Turbidity <dbl>, ParMC <dbl>, DissMC <dbl>,
#   Phycocyanin <dbl>, Chla <dbl>, DOC <dbl>, SRP <dbl>, TP <dbl>, TDP <dbl>,
#   POC <dbl>, PON <dbl>, PP <dbl>, N.P <dbl>, Nitrate <dbl>, Ammonia <dbl>,
#   H2O2 <dbl>, CDOM <dbl>, sequencingDepth <int>, pcoa.1 <dbl>, pcoa.2 <dbl>,
#   nmds.1 <dbl>, nmds.2 <dbl>

Let’s save all of our hard work.

erie_meta %>% saveRDS("clean_data/erie_meta_ordination.rds")

PCoA and NMDS plots

Before we do any plotting we will turn the month variable into a factor. We do this simply for the purpose of plotting. By default, ggplot will order character variables alphabetically, so that the months would be ordered August, July, June, October, September in plot legends. In contrast, we can specify the order of factor variables however we like.

erie_meta <- erie_meta %>%
  mutate(Month = 
           factor(Month, levels = c("June", "July", "August", "September", "October")))

Let’s create our PCoA plot.

ggplot(data = erie_meta, mapping = aes(x = pcoa.1, y = pcoa.2, color = Month, shape = Station)) +
  scale_color_manual(values = c("#a65628", "red", "#ffae19",
    "#4daf4a", "#1919ff", "darkorchid3", "magenta")
  ) +
  geom_point(aes(color = Month), alpha = 0.7, size = 4) +
  geom_point(colour = "grey90", size = 1.5) +
  theme_bw()

And now we plot create the NMDS plot.

ggplot(data = erie_meta, mapping = aes(x = nmds.1, y = nmds.2, color = Month, shape = Station)) +
  scale_color_manual(values = c("#a65628", "red", "#ffae19",
    "#4daf4a", "#1919ff", "darkorchid3", "magenta")
  ) +
  geom_point(aes(color = Month), alpha = 0.7, size = 4) +
  geom_point(colour = "grey90", size = 1.5) +
  theme_bw()