18  Haplotype calculation and Network Visualization

geneHapR provides a good wrapper function of calling pegas::haploNet() which calculates a parsinomy network. Although this only calculates haplotype assignment and network structure, it does not give any statistical evidence on biogeographic pattern.

18.1 AMOVA

= Analysis of Molecular Variance

18.1.1 Methodology 1

  • Data: molecular data, DNA sequence, microsatellite genotypes, SNPs. Arranged according to levels of hierarchical population structure.
  • DIstance Calculations: Genetic distance as a middle step in AMOVA, pairwise between individuals or haplotypes.
  • Variance Partitioning: Total genetic variance is partitioned into components.

18.1.2 AMOVA in R

{poppr} gives nice wrapper function of AMOVA in {ade4} and in {pegas}. Defualt using {ade4}

library(adegenet)
library(poppr)

genind <- df2genind(
  X = as.data.frame(gal$haps, row.names = row.names(gal$voucher)),
  ploidy = 1,
  ind.names = row.names(gal$voucher),
  pop = gal$loc_name
)

strata(genind) <- data.frame(loc_name = gal$loc_name, depth = gal$depth, lon = gal$lon, lat = gal$lat, station = gal$station) 

poppr.amova(genind, hier = ~ population/location, method = "ade4")

gal is a simple data frame including haplotype assignment $haps, sample name $voucher and environmental data. Names can be aribtrary. strata() is a framework inherited from {adegenet}.

poppr.amova() takes genind class from {adegenet} and defines the population hierarchy with hier = ~ POPULATION/SUBPOPULATION. Here in example we define geo location $loc_name as the highest group and the station under each location category as the second population level. This will yield all \(\Phi\) statistics, if only one level is provided, only the \(\Phi_{ST}\) will be calculated.

The function yields following result:

> amova.res <- poppr.amova(genind, hier = ~ population/location, method = "ade4")

 No missing values detected.

> amova.res
$call
ade4::amova(samples = xtab, distances = xdist, structures = xstruct)

$results
                                  Df    Sum Sq   Mean Sq
Between population                 1  1.345002 1.3450017
Between samples Within population  6  6.142929 1.0238215
Within samples                    50 42.650000 0.8530000
Total                             57 50.137931 0.8796128

$componentsofcovariance
                                                    Sigma           %
Variations  Between population                0.005252287   0.5904814
Variations  Between samples Within population 0.031240148   3.5121319
Variations  Within samples                    0.853000000  95.8973867
Total variations                              0.889492435 100.0000000

$statphi
                               Phi
Phi-samples-total      0.041026133
Phi-samples-population 0.035329936
Phi-population-total   0.005904814

It was a pain to understand all the three statistics, let’s say we define the hierarchy as: level1 is the highest category and level2 is enclosed by level1, which will be hier = ~ level1/level2. E.g. level1 is ocean (Pacific, Atlantic, SO, etc), level2 is the abyssal plains within each ocean.

Statistics Definition by Excoffier, Smouse, and Quattro (1992) Explanation ** {ade4} results**
\(\Phi_{ST}\) / FST Correlation of random haplotypes within populations, relative to the total variance. How much more similar are individuals within the same level2, compared to any two individuals drawn from the whole dataset (regardless of which level2 or level1) ~ shows the porportion of total variance of the whole dataset (= species) attributed to difference among level2 Phi-samples-total
\(\Phi_{CT}\) / FCT Correlation of random haplotypes within a group of populations, relative to the total variance. How much more similar are individuals within the same level1 (regardless of level2) compare to any two individuals drawn from the whole dataset (regardless of level2 or level1) ~ shows the proportion of total variance of the whole dataset attributed to difference among level1 Phi-level1-total
\(\Phi_{SC}\) / FSC Correlation of the molecular diversity of random haplotypes within populations, relative to the variance within the group. How much more similar are individuals within the same level2, compared to any two individuals drawn from the same level1 ~ shows the proportion of total variance within the level1 attributed to difference among level2 Phi-samples-level1

A higher value of these statistics indicates that more proportion of genetic variance is due to the difference among groups at tested level (FST: ignore level1, due to level2; FCT: ignore level2, due to level1; FSC: ignore global, due to level2 in level1). The concept is somewhat similar to \(\alpha\)-, \(\beta\)-, \(\gamma\)-diversity.

A notation to the result in example of why \(\Phi_{SC}\) is negative here:

negative values implies that genetic variance at the tested level, i.e. among level2 within each level1 is less than expected by chance, which means the individuals within those level2 are not more similar to each other than individuals from other level2 within the same level1. Reason for this is clear in ACTCP, as the level2 is set to stations and level1 is set to location. There were only two locations that have more than one station, and they are outmost close to each other, where no gene flow barrier is to expected, especially for galatheae.

Of course, we can make up a p-value by repeating it (permutation test), default to 99 iterations but I will say make it 999 bc why not:

> amova.test <- randtest(amova.res, nrepet = 999)
> amova.test
class: krandtest lightkrandtest 
Monte-Carlo tests
Call: randtest.amova(xtest = amova.res, nrepet = 999)

Number of tests:   3 

Adjustment method for multiple comparisons:   none 
Permutation number:   999 
                           Test         Obs    Std.Obs   Alter Pvalue
1     Variations within samples 0.853000000 -1.4197886    less  0.094
2    Variations between samples 0.031240148  0.9455456 greater  0.159
3 Variations between population 0.005252287  0.6289330 greater  0.191

plot() the result will also show the statistics distribution in histogram. What we are testing here is to see if our observed statistics are different from those generated with null model (by randomly allocating individuals among, hence permutation). Alter also shows in case of siginificance, is the observed value smaller or greater than those from permutation tests. In ACTCP, none of them is significant, means none of the tested level shapes the genetic variability in our population.

When applying this to NE area:

> amovaNE.res <- poppr.amova(genindNE, hier = ~location, method = "ade4")

 No missing values detected.

> amovaNE.res
$call
ade4::amova(samples = xtab, distances = xdist, structures = xstruct)

$results
                Df    Sum Sq   Mean Sq
Between samples  3  2.877778 0.9592593
Within samples  32 27.733333 0.8666667
Total           35 30.611111 0.8746032

$componentsofcovariance
                                 Sigma          %
Variations  Between samples 0.01592357   1.804186
Variations  Within samples  0.86666667  98.195814
Total variations            0.88259023 100.000000

$statphi
                         Phi
Phi-samples-total 0.01804186
> amovaNE.test <- randtest(amovaNE.res, nrepet = 999)
> amovaNE.test
Monte-Carlo test
Call: as.randtest(sim = res, obs = sigma[1])

Observation: 0.01592357 

Based on 999 replicates
Simulated p-value: 0.281 
Alternative hypothesis: greater 

     Std.Obs  Expectation     Variance 
 0.483237489 -0.002556856  0.001462523 

18.2 Redundancy Analysis

18.3 Mantel Correlogram

We take two matrices, geographical distance and genetic distance, to see if there’s any spatial correlation in terms of genetic diversity. For the finest control of choosing the most appropriate methods, we will construct distance matrix explicitly. Genetic distance is to be calculated from the sequence alignment using suitable substitution method, not just raw hamming distance (p-distance). The default for COI is the K2P (also as K80) distance which takes mutation rate difference of transition and transversion into account:

# seqs.clade is generated by geneHapR::import_seqs() to cooperate with geneHapR::seq2hap() function later. Every multiple alignment object accepted by as.DNAbin() would do here. 

k2p_dist_sp <- dist.dna(as.DNAbin(seqs.clade), model = "K80", pairwise.deletion = T) %>% as.matrix()

Geographic distance matrix can be tricky, depending on what type of data we have. borcard2018, p.305 used XY cartesian coordinates because the study area is small enough (few meters) and vegan::mantel.correlog() accepts cartesian coordinates as geographic information, and more importantly, it is used for surface trend analysis. While our samples were distributed in much larger scale, we will have to use a more precise Great-circle Distance using Haversine formula:

gal.latlon <- gal[match(rownames(as.matrix(d.K2PDist)), gal$voucher), c("lat", "lon")]
d.geo <- distm(gal.latlon, fun = distHaversine)/1000 # calculate distance and set unit to km
rownames(d.geo) <- gal$voucher
colnames(d.geo) <- gal$voucher
dist.geo = as.dist(d.geo)

Whether any further transformation is needed, based on the biological assumption. Acanthocope spp. are believed to be mobile and there shouldn’t be any serious geographical barrier within N Atlantic, which makes it simple to leave the distance as is. However if geographical isolation is to be expected due to barrier, or a strong isolation-by-distance (IBD) shall present, a transformation might be needed. (Log transformation?? {r} as.dist(log(d.geo + 1)))

Before we can throw both matrix into correlogram, another thing needs to be considered is detrending.

7.3 Multivariate Trend-Surface Analysis stated, that, when our data was sampled on (along) geographic surfaces, there would, very probably, be a large scale structure present as a form of gradient - linear trend. Such trend can be detected using trend-surface analysis, and also should be removed for more complex spatial analysis.

To test for linear surface trend, we need to first map our data onto a plane. Which we choose the Azimuthal Equal Distance Projection, which then return our station in a cartesian system based on the centroid we calculate as the middle point of the bounding box.

gal.xy <- gal[match(rownames(as.matrix(d.K2PDist)), gal$voucher), c("lat", "lon")]

# Convert to sf object
gal.sf <- st_as_sf(gal.xy, coords = c("lon", "lat"), crs = 4326)

# Find a suitable center point (mean of your coordinates)
center_lon <- mean(gal.xy$lon)
center_lat <- mean(gal.xy$lat)

# Create azimuthal equidistant projection string
aeqd_proj <- sprintf("+proj=aeqd +lat_0=%f +lon_0=%f +units=km", center_lat, center_lon)

# Transform to projected coordinates
gal.xy.aeqd <- st_transform(gal.sf, crs = aeqd_proj)
xy_cartesian <- as.data.frame(st_coordinates(gal.xy.aeqd))  # x/y in kilometers

We then start calculating the polynomial terms and test for linear, quadratic, and cubic trends:

library(ape) 
library(spdep) 
library(ade4) 
library(adegraphics) 
library(adespatial) 
library(vegan)

xy.c <- scale(xy_cartesian, center = TRUE, scale = FALSE)
gal.poly <- poly(as.matrix(xy.c), degree = 3, raw = TRUE) 
colnames(gal.poly) <- c("X", "X2", "X3", "Y", "XY", "X2Y", "Y2", "XY2", "Y3")

d.h <- decostand(d.K2PDist, method = "hellinger")
gal.trend.rda <- rda(d.h ~ ., data = as.data.frame(gal.poly))
R2adj.poly <- RsquareAdj(gal.trend.rda)$adj.r.squared

gal.poly.ortho <- poly(as.matrix(xy_cartesian), degree = 3) 
colnames(gal.poly.ortho) <- c("X", "X2", "X3", "Y", "XY", "X2Y", "Y2", "XY2", "Y3") 
(gal.trend.rda.ortho <- rda(d.h ~ ., data =as.data.frame(gal.poly.ortho))) 
(R2adj.poly2 <- RsquareAdj(gal.trend.rda.ortho)$adj.r.squared)

(gal.trend.fwd <- forward.sel(d.h, gal.poly.ortho, adjR2thresh = R2adj.poly2))
(gal.trend.rda2 <- rda(d.h ~ ., data = as.data.frame(gal.poly)[ ,gal.trend.fwd[ ,2]]))

anova(gal.trend.rda2) 
anova(gal.trend.rda2, by = "axis")

gal.trend.fit <- scores(gal.trend.rda2, choices = 1:4, display = "lc", scaling = 1) 
s.value(xy_cartesian, gal.trend.fit, symbol = "circle")

anova(rda(d.h, xy_cartesian))

The last anova() will yield the result, if any linear trend is present in the data or not. If true, then the subsequent analysis (e.g. Correlogram, eigenvector based analysis, etc) should be performed with detrended data. If not, it should then be proceeded with raw data. galatheae data showed a significant trend, so I decided to show the result of all three (raw, linear detrended, gam detrended) analysis and draw more conclusion by comparing them together.

18.3.1 Raw Matrix

Using raw distance is the simplest by just throwing two matrices into the function. nperm sets the number of permutation, default to 999, but we use 5000 for more reliable result, it won’t take long.

breakpoints <- c(0, 368, 1841, 2209, 4420)

gal.correlog.dist <- mantel.correlog(d.K2PDist, dist.geo, nperm = 5000, cutoff = F, break.pts = breakpoints)

The result can be print() out or plot() out to see the mantel statistics of each distance class. cutoff retains the result of the last few distance classes. break.pts allows user to set break points of the station specifically, if NULL then it will be done automatically (details see help document). I set the break points in the way that the involving pairwise comparison of each distance class has more intuitive biological meaning (see Result)

18.3.2 Linear Detrending

# Flatten distance matrices to vectors
genetic_vec <- as.matrix(as.dist(d.K2PDist))
geo_vec <- as.matrix(dist.geo)

# Linear model: genetic distance ~ geographic distance
lm_fit <- lm(genetic_vec ~ geo_vec)
genetic_resid_linear <- dist(resid(lm_fit))

gal.correlog.dist.linear <- mantel.correlog(genetic_resid_linear, dist.geo, nperm = 5000, cutoff = F, break.pts = breakpoints)

We won’t using anything generated from trend analysis for detrend work, as we will stick to our great-circle distance and avoid distortion by mapping our geographical station onto a 2D-plane.

18.3.3 GAM Detrending

Generalized Additive Model (GAM) focuses on detecting smooth functions, that is not necessarily linear. Using GAM function to detrend will capture bigger variation of spatial structure, including lineara, and some other smooth trend.

library(mgcv)
gam_fit <- gam(as.vector(genetic_vec) ~ s(as.vector(geo_vec)))

#
# Assuming you have xy_cartesian with columns x and y (projected coordinates)
xy_cartesian <- as.data.frame(st_coordinates(gal.xy.aeqd))
names(xy_cartesian) <- c("x", "y")

# Make sure the order matches your genetic distance matrix
# Flatten distance matrices to vectors
genetic_vec <- as.vector(as.dist(d.K2PDist))

# Create all pairwise combinations of coordinates
coords <- xy_cartesian
n <- nrow(coords)
pair_idx <- which(upper.tri(matrix(NA, n, n)), arr.ind = TRUE)
x1 <- coords$x[pair_idx[,1]]
y1 <- coords$y[pair_idx[,1]]
x2 <- coords$x[pair_idx[,2]]
y2 <- coords$y[pair_idx[,2]]

# Calculate pairwise midpoints (or differences)
mid_x <- (x1 + x2)/2
mid_y <- (y1 + y2)/2

# Fit GAM with spatial smooth
gam_fit_spatial <- gam(genetic_vec ~ s(mid_x, mid_y))

# Plot residuals
resid_gam_spatial <- resid(gam_fit_spatial)
plot(mid_x, resid_gam_spatial, pch=16, col=rgb(0,0,0,0.2), xlab="Midpoint X", ylab="GAM Residuals")
abline(h=0, col="red", lty=2)

# After fitting your GAM (1D or 2D), get residuals
genetic_resid_gam <- numeric(n * (n - 1) / 2)
genetic_resid_gam <- resid(gam_fit) # or resid_gam_spatial

# Build a symmetric matrix from the residuals
resid_mat <- matrix(0, n, n)
resid_mat[upper.tri(resid_mat)] <- genetic_resid_gam
resid_mat <- resid_mat + t(resid_mat) # make symmetric

# Convert to dist object for vegan::mantel.correlog
resid_dist <- as.dist(resid_mat)

library(vegan)
mantel_correlog_result <- mantel.correlog(D.eco = resid_dist, D.geo = dist.geo, nperm = 999, cutoff = FALSE, break.pts = breakpoints)

18.3.4 Show involved pairwise comparison for each distance class


#### Show involved stations

station_names <- gal[match(rownames(as.matrix(d.K2PDist)), gal$voucher), c("lon", "lat", "station")]$station
# convert to number on the map
ebs.label <- readRDS("analysis/temp/GIS_ebs.label.RDS")
ebs.label.LUT <- ebs.label$loc_label
names(ebs.label.LUT) <- ebs.label$Station
# synonymized stations:
ebs.label.LUT.syn <- c(ebs.label.LUT["81"], ebs.label.LUT["6-7"], ebs.label.LUT["4-8"])
names(ebs.label.LUT.syn) <- c("85", "6-8", "4-9")
ebs.label.LUT <- c(ebs.label.LUT, ebs.label.LUT.syn)

station_names <- ebs.label.LUT[station_names]

dmat <- as.matrix(dist.geo)
breaks <- gal.correlog.dist.linear$break.pts

output <- c()

for (i in 1:(length(breaks)-1)) {
    output <- c(output, paste("Distance class", i, ":", round(breaks[i], 4), "to", round(breaks[i+1], 4), "\n"))
    # Find indices for upper triangle only to avoid duplicates and self-pairs
    idx <- which(dmat > breaks[i] & dmat <= breaks[i+1] & upper.tri(dmat), arr.ind = TRUE)
    # Ensure idx is always a matrix
    if (length(idx) == 0) {
      output <- c(output, paste("  No pairs in this class.\n\n"))
      next
    }
    if (is.null(dim(idx))) idx <- t(as.matrix(idx)) # convert vector to 1-row matrix if needed
    # Print station pairs
    pairs <- data.frame()
    for (j in 1:nrow(idx)) {
        pairs <- rbind(pairs, data.frame(st1 = station_names[idx[j,1]], st2 = station_names[idx[j,2]]))
    }
    pairs <- pairs %>% 
        mutate(pair = paste(pmin(st1, st2), pmax(st1, st2), sep = ";")) %>% 
        distinct(pair, .keep_all = T)
    # output <- c(output, paste("  ", station_names[idx[j,1]], "<->", station_names[idx[j,2]], "\n"))
    output <- c(output, paste("  ", pairs$st1, "<->", pairs$st2, "\n"))
    output <- c(output, paste("\n"))
  }

cat(output)

18.3.5 Result

> cat(output)
Distance class 1 : 0 to 368 
    3 <-> 3 
    4 <-> 4 
    5 <-> 5 
    6 <-> 6 
    11 <-> 11 
    10 <-> 10 
    9 <-> 9 
    12 <-> 12 
 
 Distance class 2 : 368 to 1841 
    3 <-> 4 
    3 <-> 6 
    4 <-> 6 
    3 <-> 5 
    4 <-> 5 
    6 <-> 5 
    11 <-> 10 
    11 <-> 9 
    10 <-> 9 
    11 <-> 12 
    10 <-> 12 
 
 Distance class 3 : 1841 to 2209 
    9 <-> 12 
 
 Distance class 4 : 2209 to 4420 
    3 <-> 11 
    4 <-> 11 
    6 <-> 11 
    5 <-> 11 
    3 <-> 10 
    4 <-> 10 
    6 <-> 10 
    5 <-> 10 
    3 <-> 9 
    4 <-> 9 
    6 <-> 9 
    5 <-> 9 
    3 <-> 12 
    4 <-> 12 
    6 <-> 12 
    5 <-> 12 
 
> gal.correlog.dist

Mantel Correlogram Analysis

Call:
 
mantel.correlog(D.eco = d.K2PDist, D.geo = dist.geo, break.pts = breakpoints,      cutoff = F, nperm = 5000) 

       class.index      n.dist  Mantel.cor Pr(Mantel) Pr(corrected)    
D.cl.1  184.000000  756.000000    0.274177     0.0004     0.0003999 ***
D.cl.2 1104.500000  918.000000   -0.105322     0.0204     0.0203959 *  
D.cl.3 2025.000000   48.000000    0.010558     0.3591     0.3591282    
D.cl.4 3314.500000 1584.000000   -0.138607     0.0056     0.0167966 *  
---
Signif. codes:  0 ‘***’ 0.001 ‘**’ 0.01 ‘*’ 0.05 ‘.’ 0.1 ‘ ’ 1
> gal.correlog.dist.linear

Mantel Correlogram Analysis

Call:
 
mantel.correlog(D.eco = genetic_resid_linear, D.geo = dist.geo,      break.pts = breakpoints, cutoff = F, nperm = 5000) 

       class.index      n.dist  Mantel.cor Pr(Mantel) Pr(corrected)  
D.cl.1  184.000000  756.000000    0.123227     0.0406       0.04059 *
D.cl.2 1104.500000  918.000000   -0.012545     0.3855       0.38552  
D.cl.3 2025.000000   48.000000   -0.054510     0.1450       0.28994  
D.cl.4 3314.500000 1584.000000   -0.079297     0.0394       0.15757  
---
Signif. codes:  0 ‘***’ 0.001 ‘**’ 0.01 ‘*’ 0.05 ‘.’ 0.1 ‘ ’ 1
> print(mantel_correlog_result)

Mantel Correlogram Analysis

Call:
 
mantel.correlog(D.eco = resid_dist, D.geo = dist.geo, break.pts = breakpoints,      cutoff = FALSE, nperm = 5000) 

       class.index      n.dist  Mantel.cor Pr(Mantel) Pr(corrected)
D.cl.1  1.8400e+02  7.5600e+02 -3.2355e-02     0.1830        0.1830
D.cl.2  1.1045e+03  9.1800e+02 -2.0709e-03     0.4555        0.4555
D.cl.3  2.0250e+03  4.8000e+01 -3.8618e-02     0.0944        0.2831
D.cl.4  3.3145e+03  1.5840e+03  3.8304e-02     0.0774        0.3095
  • SAMOVA: original annealing approach only available on software on Windows: SAMOVA 2.0
  • Genetic Landscape Shape Interpolation: Powerful and complex tool cooperating with extensive environmental and geographical data, worth if genetic structure is known to vary due to spatial parameters. Original method
  • Mantel Test with Network Distance
  • Moran’s I or Mantel Correlogram
  • Redundancy Analysis (RDA)

  1. https://openpress.wheatoncollege.edu/molecularecologyv1/chapter/a-brief-overview-of-analysis-of-molecular-variance/↩︎