Erie Data: Relative Abundance

Introduction

In this tutorial we will continue to work with the Lake Erie data set. This time, however, we will start with the clean data that we created last time. Our goal is to create a plot that shows how bacterial communities change over time at the three sampling sites.

The OTUs in the data are classified to the genus level. There are 806 genera in the taxonomy table even after the cleaning we did last time. In order to easily visualize the changes in the bacterial communities we will aggregate the data at the phylum level, so that we are working with fewer (but larger) taxonomic groups. After aggregating to the phylum level, we will compute the relative abundance of the phyla in each sample. To simplify the data even more, we will drop the phyla that make up a small fraction of the reads in a sample. Our analysis will focus on how the relative abundance of important phyla change over time. We will plot the relative abundance data using a stacked bar plot.

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.

Don’t forget the setup information we need to put at the top of all of our Quarto documents.

Setup and reading in the clean data

Let’s start by cleaning up our work space and loading the required packages.

rm(list = ls())
gc()
          used (Mb) gc trigger (Mb) limit (Mb) max used (Mb)
Ncells  579878 31.0    1322072 70.7         NA   669442 35.8
Vcells 1069679  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(lubridate)

Next we load the data that we cleaned in the last tutorial. If you saved the files in the correct place (the clean_data folder) and used the same names as I did, then the following code should work without modification.

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))

Aggregating the data to the phylum level

Let’s remind ourselves how the erie (shared) data are structured. There is one row per sample. All but the first three columns list the abundance (number of reads) of seqeuences associated with the OTUs. Since there are 7522 OTUs in the data, there are a lot of columns (7525).

erie
# A tibble: 56 × 7,525
   label SampleID  numOtus Otu00002 Otu00004 Otu00005 Otu00006 Otu00007 Otu00008
   <dbl> <chr>       <int>    <int>    <int>    <int>    <int>    <int>    <int>
 1  0.03 E0010_CNA   19656     1271     3264        3       41      110     1351
 2  0.03 E0012_CNA   19656      672     2273        9       71      779     1142
 3  0.03 E0014_CNA   19656     1450     2874        5       15      283      791
 4  0.03 E0016_CNA   19656     1135     1897        0      204      923      709
 5  0.03 E0018_CNA   19656     1297     2689        0      264     1701     1327
 6  0.03 E0020_CNA   19656     1238     2835      116      435     1149     1462
 7  0.03 E0022_CNA   19656     1340     3049       60      637     1014     1419
 8  0.03 E0024_CNA   19656     1155     2226        7      645      917     1283
 9  0.03 E0026_CNA   19656     2119     4272      122     1323      683     1788
10  0.03 E0028_CNA   19656      880     1985      425     1803      869      741
# ℹ 46 more rows
# ℹ 7,516 more variables: Otu00010 <int>, Otu00011 <int>, Otu00012 <int>,
#   Otu00013 <int>, Otu00014 <int>, Otu00015 <int>, Otu00016 <int>,
#   Otu00017 <int>, Otu00018 <int>, Otu00019 <int>, Otu00021 <int>,
#   Otu00023 <int>, Otu00024 <int>, Otu00025 <int>, Otu00026 <int>,
#   Otu00027 <int>, Otu00028 <int>, Otu00029 <int>, Otu00030 <int>,
#   Otu00031 <int>, Otu00032 <int>, Otu00033 <int>, Otu00034 <int>, …

Lengthening the data

It is sometimes helpful to change the shape of a data set, especially preceding aggregation or some other summary computation. It is important to note that changing the shape of a data set does not change the information in the data set. Typically, we will change the shape of a data set by transforming it so that it has more rows and fewer columns (lengthening the data) or so that it has more columns and fewer rows (widening the data).

We will make a long version of the erie data so that we can more more easily aggregate at the phylum level. This will also make the data more suitable for plotting later (ggplot works best with long data formats). To lengthen or widen a data set we use pivot commands. Let’s pivot!

Our goal is to transform the data so that there is a row for each OTU in each sample. We already have a SampleID column. We need to add an OTU column so we know which OTU a row corresponds with. We also need to add a column to keep track of the abundance (number of reads) for each OTU in each sample. Before we do anything we drop the extraneous label and numOtus columns.

The following command tells R that we want to lengthen our data (pivot_longer), taking all of the columns that start with the string “Otu” and putting their names in a new column called OTU and their values in a new column called Abundance.

erie <- erie %>% select(-c(label,numOtus)) %>%
  pivot_longer(cols = starts_with("Otu"),
               names_to = "OTU",
               values_to = "Abundance")
erie
# A tibble: 421,232 × 3
   SampleID  OTU      Abundance
   <chr>     <chr>        <int>
 1 E0010_CNA Otu00002      1271
 2 E0010_CNA Otu00004      3264
 3 E0010_CNA Otu00005         3
 4 E0010_CNA Otu00006        41
 5 E0010_CNA Otu00007       110
 6 E0010_CNA Otu00008      1351
 7 E0010_CNA Otu00010      1956
 8 E0010_CNA Otu00011      3162
 9 E0010_CNA Otu00012        48
10 E0010_CNA Otu00013       139
# ℹ 421,222 more rows

Note that we now have a row for each OTU for different sample. We now have a lot of rows (56 samples * 7522 OTUS = 411232) and only a few columns.

Simplifying the taxonomy table

Let’s remind ourselves of the structure of the taxonomy table. There is a row for each OTU. The last 7 columns give the taxonomic classification of the OTUs at descending taxonomic levels.

erie_tax
# A tibble: 7,522 × 9
   OTU        Size Kingdom  Phylum         Class      Order Family Genus Species
   <chr>     <dbl> <chr>    <chr>          <chr>      <chr> <chr>  <chr> <chr>  
 1 Otu00002 280694 Bacteria Proteobacteria Betaprote… Burk… Comam… Limn… ""     
 2 Otu00004 564953 Bacteria Actinobacteria Actinobac… Fran… Spori… hgcI… ""     
 3 Otu00005 388677 Bacteria Cyanobacteria  Cyanobact… Subs… Famil… Micr… ""     
 4 Otu00006 447037 Bacteria Actinobacteria Actinobac… Fran… Spori… hgcI… ""     
 5 Otu00007 134495 Bacteria Cyanobacteria  Cyanobact… Subs… Famil… Syne… ""     
 6 Otu00008 143655 Bacteria Bacteroidetes  Sphingoba… Sphi… Chiti… Sedi… ""     
 7 Otu00010 147682 Bacteria Actinobacteria Actinobac… Fran… Spori… uncl… ""     
 8 Otu00011 241217 Bacteria Actinobacteria Actinobac… Fran… Spori… hgcI… ""     
 9 Otu00012  67816 Bacteria Proteobacteria Alphaprot… Rhod… Aceto… Rose… ""     
10 Otu00013  58499 Bacteria Proteobacteria Betaprote… Burk… Oxalo… uncl… ""     
# ℹ 7,512 more rows

Since our goal is to eventually aggregate at the phylum level, let’s create a simplified taxonomy table that just has OTU, and Phylum information (remember we have already filtered out everyhing but bacteria). This is easy to do with the select function, whose job is to select columns to retain in the data.

erie_tax_phylum <- erie_tax %>% 
  select(OTU, Phylum)
erie_tax_phylum
# A tibble: 7,522 × 2
   OTU      Phylum        
   <chr>    <chr>         
 1 Otu00002 Proteobacteria
 2 Otu00004 Actinobacteria
 3 Otu00005 Cyanobacteria 
 4 Otu00006 Actinobacteria
 5 Otu00007 Cyanobacteria 
 6 Otu00008 Bacteroidetes 
 7 Otu00010 Actinobacteria
 8 Otu00011 Actinobacteria
 9 Otu00012 Proteobacteria
10 Otu00013 Proteobacteria
# ℹ 7,512 more rows

Labeling the phyla in the erie sample data by joining the data sets

Before we aggregate the sample data at the phylum level, we have to attach approriate phylum labels to the OTUs. In essence, we want to join the lengthened erie data, which has information about OTUs, with the simplified erie_tax_phylum taxonomoy data, which matches OTUs with phylum labels. We can use a join command to accomplish this.

This command takes the data set on the left (erie) and joins it with the data set on the right (erie_tax_phylum) in a way that retains the rows of the data set on the left (hence, left_join). The join command looks for columns that the two data sets have in common (in this case just OTU) and joins by those columns. This means that for each row in the erie data, it determines the OTU for that row, finds that OTU in the erie_tax_phylum data, and adds the data associated with that OTU (Kingdom and Phylum) to the erie data. As a result, Kingdom and Phylum columns are added to the erie data.

erie <- erie %>%
  left_join(erie_tax_phylum)
Joining with `by = join_by(OTU)`
erie
# A tibble: 421,232 × 4
   SampleID  OTU      Abundance Phylum        
   <chr>     <chr>        <int> <chr>         
 1 E0010_CNA Otu00002      1271 Proteobacteria
 2 E0010_CNA Otu00004      3264 Actinobacteria
 3 E0010_CNA Otu00005         3 Cyanobacteria 
 4 E0010_CNA Otu00006        41 Actinobacteria
 5 E0010_CNA Otu00007       110 Cyanobacteria 
 6 E0010_CNA Otu00008      1351 Bacteroidetes 
 7 E0010_CNA Otu00010      1956 Actinobacteria
 8 E0010_CNA Otu00011      3162 Actinobacteria
 9 E0010_CNA Otu00012        48 Proteobacteria
10 E0010_CNA Otu00013       139 Proteobacteria
# ℹ 421,222 more rows

Performing the aggregation

Now we aggregate the sample data. Our goal is to have one row per phylum (rather than OTU) for each sample and a column that lists the abundance (total number of reads) for each phylum in each sample.

We start by grouping the data by sampleID and Phylum. This does not change the shape of the data set or the information contained in it, it just adds a group structure. Each group will contain all of the rows associated with a single phylum within a single sample. We add this structure so that we can aggregate within each group. Note that the structure is indicated in the print out.

We remove the ungrouped data from the workspace using the rm command and free up some memory using the gc command.

erie_phylum <- erie %>% 
  group_by(SampleID, Phylum)
erie_phylum
# A tibble: 421,232 × 4
# Groups:   SampleID, Phylum [2,408]
   SampleID  OTU      Abundance Phylum        
   <chr>     <chr>        <int> <chr>         
 1 E0010_CNA Otu00002      1271 Proteobacteria
 2 E0010_CNA Otu00004      3264 Actinobacteria
 3 E0010_CNA Otu00005         3 Cyanobacteria 
 4 E0010_CNA Otu00006        41 Actinobacteria
 5 E0010_CNA Otu00007       110 Cyanobacteria 
 6 E0010_CNA Otu00008      1351 Bacteroidetes 
 7 E0010_CNA Otu00010      1956 Actinobacteria
 8 E0010_CNA Otu00011      3162 Actinobacteria
 9 E0010_CNA Otu00012        48 Proteobacteria
10 E0010_CNA Otu00013       139 Proteobacteria
# ℹ 421,222 more rows
rm(erie)
gc()
          used (Mb) gc trigger  (Mb) limit (Mb) max used  (Mb)
Ncells 1065468 57.0    2012385 107.5         NA  2012385 107.5
Vcells 3628087 27.7    8388608  64.0     102400  8082487  61.7

Now we aggregate the data. The summarize function creates a specified summary of each group in a grouped data set. Here, we want to sum the number of reads per OTU within each phylum for each sample. So, we create an Abundance column that holds the sum of the previous Abundance column within each group. Note that the resulting data set has fewer rows, and that it no longer has OTU information (since we have aggregated at the phylum level). Also note that the data are still grouped by SampleID. We will exploit this grouping later.

erie_phylum <- erie_phylum %>%
  summarize(Abundance = sum(Abundance))
`summarise()` has grouped output by 'SampleID'. You can override using the
`.groups` argument.
erie_phylum
# A tibble: 2,408 × 3
# Groups:   SampleID [56]
   SampleID  Phylum                  Abundance
   <chr>     <chr>                       <int>
 1 E0010_CNA Acidobacteria                  32
 2 E0010_CNA Actinobacteria              12128
 3 E0010_CNA Armatimonadetes                15
 4 E0010_CNA BD1-5                           3
 5 E0010_CNA Bacteroidetes                9020
 6 E0010_CNA CKC4                            0
 7 E0010_CNA Candidate_division_BRC1         1
 8 E0010_CNA Candidate_division_JS1          0
 9 E0010_CNA Candidate_division_OD1         30
10 E0010_CNA Candidate_division_OP11         0
# ℹ 2,398 more rows

In general, following a summarize operation, the resulting data frame can still be grouped if the original data frame is grouped on more than one variable. Only the grouping by the last grouping variable is removed. Since our data are grouped by SampleID and Phylum, the result is still grouped by SampleID.

Computing relative abundances

Now compute the relative abundances of each phylum in each sample. Instead of listing the number of reads for each phylum within each sample, we want to know the proportion of the total reads in the sample that are made up by each phylum. Thus, we need to divide the Abundance of each phylum in a sample by the sum of the Abundance for each sample (across all phyla in the sample).

We can use the mutate command to compute the relative abundance in a single opeation. We take advantage of the fact that the data are already grouped by SampleID. Whenever a mutate command is applied to a grouped data set, any summary operations (sum, mean, median, sd, etc.) is applied at the group level rather than across the whole data set. Thus, the sum computed in the following mutate command will be the group sum (the total number of reads for each sample).

erie_phylum <- erie_phylum %>%
  mutate(Rel_Abundance = Abundance/sum(Abundance, na.rm=TRUE))
erie_phylum
# A tibble: 2,408 × 4
# Groups:   SampleID [56]
   SampleID  Phylum                  Abundance Rel_Abundance
   <chr>     <chr>                       <int>         <dbl>
 1 E0010_CNA Acidobacteria                  32     0.00112  
 2 E0010_CNA Actinobacteria              12128     0.424    
 3 E0010_CNA Armatimonadetes                15     0.000525 
 4 E0010_CNA BD1-5                           3     0.000105 
 5 E0010_CNA Bacteroidetes                9020     0.316    
 6 E0010_CNA CKC4                            0     0        
 7 E0010_CNA Candidate_division_BRC1         1     0.0000350
 8 E0010_CNA Candidate_division_JS1          0     0        
 9 E0010_CNA Candidate_division_OD1         30     0.00105  
10 E0010_CNA Candidate_division_OP11         0     0        
# ℹ 2,398 more rows

Filtering out uncommon phyla

Some of the phyla make up only a small proportion of the sample. We will simplify the data set by removing any phyla that make up 2% or less of each sample. Before we do that let’s take a look at how many phyla are present in the entire data set before filtering. There are currently 43 phyla.

erie_phylum %>% ungroup %>% count(Phylum) # ungroup so we are not counting phyla within each Sample
# A tibble: 43 × 2
   Phylum                      n
   <chr>                   <int>
 1 Acidobacteria              56
 2 Actinobacteria             56
 3 Armatimonadetes            56
 4 BD1-5                      56
 5 Bacteroidetes              56
 6 CKC4                       56
 7 Candidate_division_BRC1    56
 8 Candidate_division_JS1     56
 9 Candidate_division_OD1     56
10 Candidate_division_OP11    56
# ℹ 33 more rows

We filter out the uncommon phyla in each sample with the filter command. The resulting data set has fewer rows (333 instead of 2408).

erie_phylum <- erie_phylum %>%
  filter(Rel_Abundance > 0.02)
erie_phylum
# A tibble: 333 × 4
# Groups:   SampleID [56]
   SampleID  Phylum          Abundance Rel_Abundance
   <chr>     <chr>               <int>         <dbl>
 1 E0010_CNA Actinobacteria      12128        0.424 
 2 E0010_CNA Bacteroidetes        9020        0.316 
 3 E0010_CNA Proteobacteria       5953        0.208 
 4 E0010_CNA Verrucomicrobia       798        0.0279
 5 E0012_CNA Actinobacteria       9173        0.383 
 6 E0012_CNA Bacteroidetes        7269        0.303 
 7 E0012_CNA Cyanobacteria         835        0.0348
 8 E0012_CNA Proteobacteria       4490        0.187 
 9 E0012_CNA Verrucomicrobia      1277        0.0533
10 E0014_CNA Actinobacteria       9083        0.362 
# ℹ 323 more rows

Have any phyla been removed from the data altogether? Let’s count the remaining phyla. Only 8 remain! The table shows how many samples they appear in (there are 56 samples).

erie_phylum %>% ungroup %>% count(Phylum)
# A tibble: 8 × 2
  Phylum              n
  <chr>           <int>
1 Actinobacteria     56
2 Armatimonadetes     2
3 Bacteroidetes      56
4 Chlorobi           11
5 Cyanobacteria      50
6 Planctomycetes     46
7 Proteobacteria     56
8 Verrucomicrobia    56

Let’s export the aggregated and filtered data so that we can use it later without repeating the computations we have done. We’ll save an ungrouped version of the data so that we don’t have to remember about the groups structure later.

erie_phylum %>% ungroup %>% 
  saveRDS("clean_data/erie_phylum.rds")

Joining the sample data with the metadata

Now we can join the the sample data with the metadata, which has information we will want to incorporate in our plot. In particular, we would like to see how relative abundance changes over time at each sample site. Thus, we need to know when each sample was collected and which site it was collected at. This information is in the metadata. We use a join command like we did earlier. We want to add information from the metadata to the erie_phylum data, so we perform the join in a way that will preserve the rows of the erie_phylum data. The data will be joined by the columns they have in common (in this case, just SampleID).

erie_joined <- erie_phylum %>% 
  left_join(erie_meta)
Joining with `by = join_by(SampleID)`
erie_joined
# A tibble: 333 × 36
# Groups:   SampleID [56]
   SampleID  Phylum    Abundance Rel_Abundance Date       Station Shore Fraction
   <chr>     <chr>         <int>         <dbl> <date>     <chr>   <chr> <chr>   
 1 E0010_CNA Actinoba…     12128        0.424  2014-06-16 nearsh… near… CNA     
 2 E0010_CNA Bacteroi…      9020        0.316  2014-06-16 nearsh… near… CNA     
 3 E0010_CNA Proteoba…      5953        0.208  2014-06-16 nearsh… near… CNA     
 4 E0010_CNA Verrucom…       798        0.0279 2014-06-16 nearsh… near… CNA     
 5 E0012_CNA Actinoba…      9173        0.383  2014-06-16 nearsh… near… CNA     
 6 E0012_CNA Bacteroi…      7269        0.303  2014-06-16 nearsh… near… CNA     
 7 E0012_CNA Cyanobac…       835        0.0348 2014-06-16 nearsh… near… CNA     
 8 E0012_CNA Proteoba…      4490        0.187  2014-06-16 nearsh… near… CNA     
 9 E0012_CNA Verrucom…      1277        0.0533 2014-06-16 nearsh… near… CNA     
10 E0014_CNA Actinoba…      9083        0.362  2014-06-16 offsho… offs… CNA     
# ℹ 323 more rows
# ℹ 28 more variables: Type <chr>, Samplenum <chr>, Date_year <chr>,
#   Month <chr>, 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>

Relative abundance stacked bar plots

Before we do any plotting, we will specify the colors that we will use to represent the phyla. We will use the colors from Michelle Berry’s Phyloseq tutorial. There are 16 colors, but we will only use 8 (one color per phylum).

phylum_colors <- c(
  "#CBD588", "#5F7FC7", "orange","#DA5724", "#508578", "#CD9BCD",
   "#AD6F3B", "#673770","#D14285", "#652926", "#C84248", 
  "#8569D5", "#5E738F","#D1A33D", "#8A7C64", "#599861"
)

We will construct two versions of the plot. In both cases we will plot relative abundance on the y axis and date on the x axis. In the first case we will plot the dates in such a way that they occur at the correct relative position on the x axis. This shows the irregularity in the sample collection–larger white spaces between bars reflect longer intervals between samples. In the second case, we will have the same amount of white space between each bar, allowing us to focus more on the varition in relative abundance.

The structure of the ggplot command is familiar. We use Phylum to determine the fill color within each bar. geom_bar adds the bars to the plot. The facet_grid command tells R to break the plot into a grid of vertically arranged subplots with one subplot per Station. The rest of the code determines how the plot will look, including specifying that we want to use colors from the phylum_colors vector.

ggplot(erie_joined, aes(x = Date, y = Rel_Abundance, fill = Phylum)) + 
  facet_grid(Station~.) +
  geom_bar(stat = "identity") +
  scale_fill_manual(values = phylum_colors) +
  theme_bw() +
  # Remove x axis title
  theme(axis.title.x = element_blank()) + 
  guides(fill = guide_legend(reverse = FALSE, keywidth = 1, keyheight = 1)) +
  ylab("Relative Abundance (Phyla > 2%) \n") +
  ggtitle("Phylum Composition of Lake Erie \n Bacterial Communities by Sampling Site") 

For the second version, we treat date as a character (instead of a date) so that there is not irregular spacing between bars that results from irregular sampling intervals.

ggplot(erie_joined, aes(x = format(Date, "%m/%d"), y = Rel_Abundance, fill = Phylum)) + 
  facet_grid(Station~.) +
  geom_bar(stat = "identity") +
  scale_fill_manual(values = phylum_colors) +
  scale_x_discrete(
    breaks = c("07/08", "08/04", "09/02", "10/06"),
    labels = c("Jul", "Aug", "Sep", "Oct"),
    drop = FALSE
  ) +
  theme_bw() +
  # Remove x axis title
  theme(axis.title.x = element_blank()) + 
  guides(fill = guide_legend(reverse = FALSE, keywidth = 1, keyheight = 1)) +
  ylab("Relative Abundance (Phyla > 2%) \n") +
  ggtitle("Phylum Composition of Lake Erie \n Bacterial Communities by Sampling Site") 

Saving the plot

Let’s save the second version of the plot so that we can easily refer to it later. We will save it in the outputs subdirectory of the watershedErie directory, since it is an ouput. We will save the plot as a pdf file using the ggsave command. After you save the plot open it up and see what it looks like.

ggsave('outputs/Erie_Phylum_relativeAbundance_stackedBar.pdf')
Saving 7 x 5 in image