Using {nichetools} with SIBER

Benjamin L. Hlina

2024-08-29

Our Objectives

The purpose of this vignette is to use {SIBER} and {nichetools} to extract and then visualize estimates of trophic niche size and similarities and Layman community metrics for multiple freshwater fish using {ggplot2}.

This vignette can be used for additional purposes including estimating niche size and similarities among different groups of aquatic and/or terrestrial species. Furthermore, niche size and similarities for different behaviours exhibited within a population can be made using behavioural data generated from acoustic telemetry (e.g., differences in habitat occupancy).

Bring in trophic data

First we will load the necessary packages to preform the analysis and visualization. We will use {SIBER} and {nichetools} to preform the analysis. We will use {bayestestR} to calculate and extract medians and Equal-Tailed Interval (ETI) of posterior distributions that we want to plot. We will use {dplyr}, {tidyr}, and {purrr} to manipulate data and iterate processes. Lastly, we will use {ggplot2}, {ggtext}, and {ggdist} to plot and add labels.

I will add that many of the {dplyr} and {tidyr} functions and processes can be replaced using {data.table} which is great when working with large data sets.

We will first load the packages that were just mentioned

{
  library(bayestestR)
  library(dplyr)
  library(ggplot2)
  library(ggdist)
  library(ggtext)
  library(nichetools)
  library(purrr)
  library(SIBER)
  library(tidyr)
  library(viridis)
}

For the purpose of the vignette we will be using the demo.siber.data.2 data frame that is available within {SIBER}. We will first look at the structure of this data frame using the {dplyr} function glimpse(). For your purposes you will need to replace this with your data frame either by loading a csv, rds, or qs file. You can do this multiple ways, I prefer using readr::read_csv() for a csv but base R’s read.csv() works perfectly fine. Note, {SIBER} functions do not take tibbles or data.tables so you will have to convert either to data.frame class prior to running functions in {SIBER}.

glimpse(demo.siber.data.2)
#> Rows: 150
#> Columns: 4
#> $ iso1      <dbl> -11.048902, -8.809360, -9.256212, -8.380952, -10.401561, -7.…
#> $ iso2      <dbl> 1.02044315, 1.97597511, 3.28707260, -1.95750266, -3.11949113…
#> $ group     <chr> "city", "city", "city", "city", "city", "city", "city", "cit…
#> $ community <chr> "dublin", "dublin", "dublin", "dublin", "dublin", "dublin", …

You will notice that the community and group column are character strings that are the actual names of the communities and groups. I advise changing them into factors, thus allow you to know the order for each column prior to converting them into a numeric and then a charcter. The reason why this is important, is that functions in {SIBER} use for loops based on the indexing of the SiberObject. If this order does not match up you can have issues with the names of the communities and groups you are working with.

Let us change these into a factor, then preserve the column and create an id column that is the numerical order that will become the community and group names provided to createSiberObject().

demo.siber.data.2 <- demo.siber.data.2 %>%
  mutate(
    group = factor(group), 
    community = factor(community), 
    group_id = as.numeric(group) %>% 
      as.character(),
    community_id = as.numeric(community) %>%
      as.character()
  ) %>% 
  rename(
    group_name = group,
    community_name = community,
    group = group_id,
    community = community_id
  )

glimpse(demo.siber.data.2)
#> Rows: 150
#> Columns: 6
#> $ iso1           <dbl> -11.048902, -8.809360, -9.256212, -8.380952, -10.401561…
#> $ iso2           <dbl> 1.02044315, 1.97597511, 3.28707260, -1.95750266, -3.119…
#> $ group_name     <fct> city, city, city, city, city, city, city, city, city, c…
#> $ community_name <fct> dublin, dublin, dublin, dublin, dublin, dublin, dublin,…
#> $ group          <chr> "1", "1", "1", "1", "1", "1", "1", "1", "1", "1", "1", …
#> $ community      <chr> "1", "1", "1", "1", "1", "1", "1", "1", "1", "1", "1", …

After we have done this, we are going to create two data frames that are the names of our communities and groups with their associated id values. We will use these data frames later on to join up the actual names, allowing us to know what estimates belong to which communities and groups. We are doing this because it is unlikely you will have communities and groups named 1, 2, 3 ect. and instead will have actual names.

# ---- create name with group and community data frame ----
cg_names <- demo.siber.data.2 %>%
  distinct(group,
           community, 
           group_name, 
           community_name) %>%
  
  arrange(community, group)

# ---- create community names data frame ---- 
c_names <- demo.siber.data.2 %>% 
  distinct(community, 
           community_name) %>%
  arrange(community)

We will then plot our biplot to confirm we have the correct structure.

ggplot(data = demo.siber.data.2, aes(x = iso1, y = iso2,
                                     colour = group_name)) +
  geom_point() +
  facet_wrap(~ community_name) + 
  scale_colour_viridis_d(option = "A", begin = 0.25, end = 0.85,
                         name = "Groups", alpha = 0.75) + 
  theme_bw(
    base_size = 15
  ) + 
  theme(
    strip.background = element_blank(),
    panel.grid = element_blank(), 
    axis.title = element_markdown(),
    legend.position = "inside",
    legend.position.inside = c(0.65, 0.75)
  ) + 
  labs(
    x = paste0("\U03B4","<sup>", 13, "</sup>", "C", " (‰)"),
    y = paste0("\U03B4","<sup>", 15, "</sup>", "N", " (‰)")
  )

Next we will grab the isotopes we need and the community and group ids that have already been renamed to community and group. This is important as creatSiberObject() will 1) only take the following order with the following names iso1, iso2, group, and community and 2) we will transform this tibble into a data.frame as {SIBER} will only work with a data.frame. In this case we were using tibbles but we could also be working with data.table and will need to do the same thing.

demo_siber_data <- demo.siber.data.2 %>% 
  dplyr::select(iso1, iso2, group, community) %>%
  arrange(community, group) %>% 
  as.data.frame() 

Convert to {SIBER} object

First convert to our isotope data into a {SIBER} object.

siber_example <- createSiberObject(demo_siber_data)

Now that this is a {SIBER} object we can start doing some analysis using a frequentist (e.g., maximum-likelihood) or a Bayesian framework. The data and metrics generated through this analysis by {SIBER} can be extracted using functions in {nichetools}.

Bayesian Ellipse Analysis

We first need to set the parameters to run the model

# options for running jags
parms <- list()
parms$n.iter <- 2 * 10^4   # number of iterations to run the model for
parms$n.burnin <- 1 * 10^3 # discard the first set of values
parms$n.thin <- 10     # thin the posterior by this many
parms$n.chains <- 2        # run this many chains

Next we need to define the priors for each parameter of the model. This includes fitting the ellipses using an Inverse Wishart prior on the covariance matrix (\(\Sigma\)), and a vague normal prior on the means (\(\mu\)).

# fit the ellipses which uses an Inverse Wishart prior on the 
# covariance matrix Sigma, and a vague normal prior on the 
# means.
priors <- list()
priors$R <- 1 * diag(2)
priors$k <- 2
priors$tau.mu <- 1.0E-3

We will now run the model using the function siberMVN().

ellipses_posterior <- siberMVN(siber_example, parms, priors)

Extract posterior distributions for μ and Σ

We will first extract posterior distribution for \(\mu\) using extract_mu() in {nichetools}. We will need to set the argument pkg to "SIBER" and the argument data_format to "wide". This argument takes "long" or "wide" which dictates whether the data object returned is in wide or long format. We will also use the function seperate_wider_delim() from {tidyr} to separate the community and groups names as they are joined by a .. We then will use left_join() and the cg_names data frame we created above to add in the correct community and group names.

df_mu <- extract_mu(ellipses_posterior, pkg = "SIBER", 
                    data_format = "wide", 
                    community_df = cg_names)

We can confirm that the posterior estimates of \(\mu\) are correct by plotting them with {ggplot2}.

ggplot() +
  geom_point(data = df_mu, aes(x = d13c, y = d15n,
                               colour = group_name)) +
  geom_point(data = demo.siber.data.2, aes(x = iso1, y = iso2,
                                           colour = group_name)) +
  facet_wrap( ~ community_name) + 
  scale_colour_viridis_d(option = "A", begin = 0.25, end = 0.85,
                         name = "Groups", alpha = 0.75) + 
  theme_bw(
    base_size = 15
  ) + 
  theme(
    strip.background = element_blank(),
    panel.grid = element_blank(), 
    axis.title = element_markdown(),
    legend.position = "inside",
    legend.position.inside = c(0.65, 0.75)
  ) + 
  labs(
    x = paste0("\U03B4","<sup>", 13, "</sup>", "C", " (‰)"),
    y = paste0("\U03B4","<sup>", 15, "</sup>", "N", " (‰)")
  )

Notice the density of points in the center of the raw data for the corresponding colours. This density of points are the posterior estimates from the model for \(\mu\) and are an indication that the model is iterating over the groups and communities correctly.

We are also going to extract \(\mu\) in long format for creating ellipse, as the function that create ellipse needs the data frame of \(\mu\) to be in long format.

df_mu_long <- extract_mu(ellipses_posterior, pkg = "SIBER", 
                         community_df = cg_names)

Next we are going to extract posterior estimates of \(\Sigma\) using extract_sigma().

df_sigma <- extract_sigma(ellipses_posterior, pkg = "SIBER")

Lastly, we are going to feed each \(\mu\) and \(\Sigma\) estimate to niche_ellipse() to estimate each ellipse. There are few things to know about this function and they include the following: 1) it will randomly sample 10 ellipse from the total posterior distribution of \(\mu\) and \(\Sigma\), this seems quite standard, however, you can adjust the number of samples by changing the argument n. 2) to make the function consistently randomly sample the same set you will need to set set_seed to a numerical value. If this is not set then it will randomly sample a different set of 10 ellipses every time. 3) if you would like the function to not randomly sample set the argument random to FALSE. 4) by default it will tell you how long it takes to generate the ellipse and will have progress bars at each step. If you want to turn this off set message to FALSE. 5) if you are wanting to change the confidence level of the ellipse you can do so using the argument p_ell. This value is bound between 0 and 1.

df_el <- niche_ellipse(dat_mu = df_mu_long,
                       dat_sigma = df_sigma, 
                       set_seed = 4, n = 20) %>%
  separate_wider_delim(sample_name, cols_remove = FALSE,
                       delim = ".", names = c("community",
                                              "group")) %>%
  left_join(cg_names)

Now that we have the ellipses created we can plot them.

ggplot(data = df_el, 
       aes(x = d13c, y = d15n, 
           group = interaction(sample_number, 
                               sample_name), 
           colour = group_name)) + 
  geom_polygon(linewidth = 0.5, fill = NA) + 
  facet_wrap( ~ community_name) + 
  scale_colour_viridis_d(option = "A", begin = 0.25, end = 0.85,
                         name = "Groups", alpha = 0.75) + 
  theme_bw(
    base_size = 15
  ) + 
  theme(
    strip.background = element_blank(),
    panel.grid = element_blank(), 
    legend.position = "inside",
    axis.title = element_markdown(),
    legend.background = element_blank(), 
    legend.position.inside = c(0.65, 0.75)
  ) + 
  labs(
    x = paste0("\U03B4","<sup>", 13, "</sup>", "C", " (‰)"),
    y = paste0("\U03B4","<sup>", 15, "</sup>", "N", " (‰)")
  )

Extract Niche Size

We can use siberEllipses() from {SIBER} to estimate niche size for each posterior sample for each community and group.

sea_b <- siberEllipses(corrected.posteriors = ellipses_posterior)

Next using extract_niche_size() from {nichetools} we can extract niche size which will also add in the correct names for the communities and groups we are working with.

seb_convert <- extract_niche_size(data = sea_b,
                                  pkg = "SIBER",
                                  community_df = cg_names)

We will also extract the parametric estimate for niche size using groupMetricsML() from {SIBER}.

group_ml <- groupMetricsML(siber_example)

We can convert the output of this function using extract_group_metrics().

group_convert <- extract_group_metrics(data = group_ml,
                                       community_df = cg_names)

The object returned will have maximum-likelihood estimates for the standard ellipse area (SEA), the central standard ellipse area (SEAc), and the total area (TA). For plotting niche size we will use SEAc.

sea_c <- group_convert %>% 
  filter(metric == "SEAc")

Lastly, we can visualize the extracted niche sizes using {ggplot2}.

ggplot() +
  geom_violin(data = seb_convert, aes(x = community_name,
                                      y = sea,
                                      fill = group_name)) + 
  scale_fill_viridis_d(option = "A", begin = 0.25, end = 0.85,
                       name = "Groups", alpha = 0.75) + 
  geom_point(data = sea_c, aes(x = community_name, 
                               y = est, 
                               group = group_name,
  ), 
  size = 2.5,
  fill = "white",
  shape = 21,
  position = position_dodge(width = 0.9)) +
  theme_bw(
    base_size = 15
  ) + 
  theme(
    strip.background = element_blank(),
    panel.grid = element_blank(), 
    legend.position = "inside",
    legend.background = element_blank(), 
    legend.position.inside = c(0.85, 0.8)
  ) + 
  labs(
    x = "Communities", 
    y = expression(paste("Niche Size p(", "‰"^2, "| x)"))
  )

Niche Similarties

Now that we have extracted Bayesian estimates of niche size we are likely wanting to know how much do these niches have in common or are similar.

We can use maximum-likelihood and Bayesian frameworks to estimate the percentage of similarity within communities between groups and/or among communities with groups being consistent. We can use functions in {SIBER} to create maximum-likelihood and Bayesian estimates of niche similarity followed by functions in {nichetools} to extract these similarities.

First we need to create our comparisons that we are wanting to evaluate. We can use the function create_comparisons() to generate a list that has two-column data frames of each comparison. You can change the argument comparison to "among" to compare communities for the same group, versus "within" which compares groups within a community.

cg_names_within_com <- cg_names %>%
  create_comparisons(comparison = "within")

Next we can feed this listed data frames to either maxLikOverlap() or bayesianOverlap() using map() from {purrr}. For this exercise I have not changed .progress argument of map() but when working with large data sets I often turn this argument to TRUE to provide a progress update.

ml_within_overlap <- cg_names_within_com %>%
  map(~ maxLikOverlap(.x$cg_1, .x$cg_2, siber_example,
                      p.interval = 0.95, n = 100))

Next we can extract estimates using extract_similarities() with the type argument set to "ml".

ml_95_within_com <- extract_similarities(ml_within_overlap, 
                                         type = "ml", 
                                         community_df = cg_names)

Now we will repeat the process for bayesianOverlap(), first supplying the function with our list of data frames, next having map() iterate over this list.

bayes95_overlap <- cg_names_within_com %>%
  map(~ bayesianOverlap(.x$cg_1, .x$cg_2, ellipses_posterior,
                        draws = 100, p.interval = 0.95,
                        n = 100)
  )

Next we can extract estimates using extract_similarities() with the type argument set to "bay".

bays_95_overlap <- extract_similarities(bayes95_overlap, 
                                        type = "bay",
                                        community_df = cg_names)

Now that we have extracted maximum-likelihood and Bayesian estimates we can visualize them.

Prior to creating a point interval plot, we need to create a colour palette that will be used to identify each community.

viridis_colors_s <- viridis(4, begin = 0.25, end = 0.85,
                          option = "A",
                          alpha = 0.75
)

Now we can use point interval plots to visually represent posterior distributions.

ggplot() + 
  stat_pointinterval(data = bays_95_overlap,
              aes(x = group_1, 
                  y = prop_overlap, 
                  point_fill = group_2), 
    interval_colour = "grey60",
    point_size = 3,
    shape = 21,
    position = position_dodge(0.4)) + 
  
  geom_point(data = ml_95_within_com, aes(x = group_1, 
                                   y = prop_overlap,
                                   group = group_2), 
             shape = 21,
             fill = "white",
             size = 2,
             alpha = 0.5, 
             position = position_dodge(0.4)) + 
  scale_fill_manual(name = "Group", 
                    aesthetics = "point_fill",
                    values = viridis_colors_s) + 
  theme_bw(
    base_size = 15
  ) + 
  theme(
    panel.grid = element_blank(),
    strip.background = element_blank(),
    legend.position = "inside",
    legend.position.inside = c(0.15, 0.80),
  ) +
  labs(
    x = "Group",
    y = expression(paste("p(", "‰", "|X)"))
  )

Bayesian Estimates of Layman’s Community Metrics

First I highly recommend reading Layman et al. 2007 to understand the six community metrics that will be estimating using a Bayesian framework in this section.

To create maximum-likelihood estimate of Layman’s community metrics we first need to use communityMetricsML() from {SIBER}.

community_ml <- communityMetricsML(siber_example)

Then we can extract these estimates using extract_layman() with the type argument set to "ml". The default for this argument is "bay".

layman_ml <- extract_layman(community_ml, 
                            type = "ml", 
                            community_df = c_names)

To create Bayesian estimates of Layman’s community metrics we first need to use extractPosteriorMeans() from {SIBER} to extract posterior estimates of means for each community and group.

mu_post <- extractPosteriorMeans(siber_example, ellipses_posterior)

Next we need to use bayesianLayman() from {SIBER} and use our extracted posterior means to create Bayesian estimates for each community metric.

layman_b <- bayesianLayman(mu.post = mu_post)

Once we have created our Bayesian estimates for each community metric we can use extract_layman() to extract these estimates. The function will also create the variable labels which will first assign a new name to each community metric and secondly will reorder these names based on how these metrics are often viewed.

layman_be <- extract_layman(layman_b, community_df = c_names)

Prior to creating a point interval plot, we need to create a colour palette that will be used to identify each community.

viridis_colors <- viridis(2, begin = 0.25, end = 0.85,
                          option = "G",
                          alpha = 0.75
)

Lastly, we can visualize the distributions of the posterior estimates using stat_pointinterval() from {ggdist}.

ggplot() + 
  stat_pointinterval(
    data = layman_be, aes(x = labels, 
                          y = post_est, 
                          point_fill = community_name),
    point_size = 2.5,
    interval_colour = "grey60",
    position = position_dodge(0.4),
    shape = 21
  ) + 
  geom_point(data = layman_ml, aes(x = labels, 
                                   y = estimate,
                                   group = community_name), 
             shape = 21,
             fill = "white",
             alpha = 0.5, 
             position = position_dodge(0.4)) + 
  scale_fill_manual(name = "Community", 
                    aesthetics = "point_fill",
                    values = viridis_colors) + 
  theme_bw(
    base_size = 15
  ) + 
  theme(
    panel.grid = element_blank(),
    axis.text = element_markdown(),
    legend.position = "inside",
    legend.position.inside = c(0.88, 0.85),
  ) +
  labs(
    x = "Community Metrics",
    y = expression(paste("p(", "‰", "|X)"))
  )

Congratulations, we have successfully used functions from {SIBER} to extract and visually represent trophic communities and niche size and similarities. If you something doesn’t work/is confusing please reach out.