Are all GPA functions the same?

code
analysis
Author

Mike Collyer

Published

October 10, 2026

Are all GPA functions the same?

Up first, standard GPA

geomorph vs. Morpho

library(geomorph)

# make a latticed cube
vert <- 5
template <- expand.grid(1:vert, 1:vert, 1:vert)
colnames(template) <- c("x", "y", "z")

# add points for n specimens
n <- 50
LM <- LMlist <- lapply(seq_len(n), function(.){
  as.matrix(template + rnorm(length(template),
                   mean = 0, sd = 0.3))
})

LM <- simplify2array(LM)
plotAllSpecimens(LM)
system.time(GPA1 <- gpagen(LM, print.progress = FALSE))
   user  system elapsed 
  0.011   0.002   0.021 
plot(GPA1)
library(Morpho)
system.time(GPA2 <- ProcGPA(LM))
   user  system elapsed 
  0.041   0.001   0.045 
plotAllSpecimens(GPA2$rotated)
two.b.pls(GPA1$coords, GPA2$rotated, print.progress = FALSE)
Data in either A1 or A2 do not have names.  It is assumed data in both A1 and A2 are ordered the same.

Call:
two.b.pls(A1 = GPA1$coords, A2 = GPA2$rotated, print.progress = FALSE) 



r-PLS: 1

Effect Size (Z): 3.9295

P-value: 0.001

Based on 1000 random permutations

Although they seem perfectly correlated, you might notice by spinning the plots a slight difference in the scatter. With gpagen, the default is to project configurations into a space tanget to shape space. The procGPA function does not do this. So, gpagen scatter looks more planar and procGPA more hemispherical at each point. We can update this so they are the same.

system.time(GPA1 <- gpagen(LM, print.progress = FALSE, Proj = FALSE))
   user  system elapsed 
  0.004   0.000   0.004 
plot(GPA1)
two.b.pls(GPA1$coords, GPA2$rotated, print.progress = FALSE)
Data in either A1 or A2 do not have names.  It is assumed data in both A1 and A2 are ordered the same.

Call:
two.b.pls(A1 = GPA1$coords, A2 = GPA2$rotated, print.progress = FALSE) 



r-PLS: 1

Effect Size (Z): 3.9388

P-value: 0.001

Based on 1000 random permutations

Conclusions:

  1. Similar results
  2. gpagen is faster

Add in shapes::ProcGPA

library(shapes)
system.time(GPA3 <- procGPA(LM))
   user  system elapsed 
  0.194   0.007   0.207 
plotAllSpecimens(GPA2$rotated)
two.b.pls(GPA1$coords, GPA3$rotated, print.progress = FALSE)
Data in either A1 or A2 do not have names.  It is assumed data in both A1 and A2 are ordered the same.

Call:
two.b.pls(A1 = GPA1$coords, A2 = GPA3$rotated, print.progress = FALSE) 



r-PLS: 1

Effect Size (Z): 3.9389

P-value: 0.001

Based on 1000 random permutations

Concluions

  1. Similar results.
  2. gpagen is faster

Add in Momocs2::fgProcrustes

library(Momocs)
system.time(GPA4 <- fgProcrustes(LMlist, tol = 0.0001))
iteration:  1   gain: 1006700 
iteration:  2   gain: 18.202 
iteration:  3   gain: 5.9327 
iteration:  4   gain: 0.53931 
iteration:  5   gain: 0.51929 
iteration:  6   gain: 0.95641 
iteration:  7   gain: 1.2674 
iteration:  8   gain: 0.60935 
iteration:  9   gain: 0.018871 
iteration:  10  gain: 0.09019 
iteration:  11  gain: 0.034994 
iteration:  12  gain: 0.098085 
iteration:  13  gain: 0.052107 
iteration:  14  gain: 0.006952 
iteration:  15  gain: 0.019079 
iteration:  16  gain: 8.5349e-05 
   user  system elapsed 
  0.161   0.003   0.166 

Note that the function appears to work but is only intended for 2D data, so the results are wacko.

Let’s make this serious (but no plotting because the point fields are too dense!)

# make a latticed cube
vert <- 10
template <- expand.grid(1:vert, 1:vert, 1:vert)
colnames(template) <- c("x", "y", "z")

# add points for n specimens
n <- 100
LM <- LMlist <- lapply(seq_len(n), function(.){
  as.matrix(template + rnorm(length(template),
                   mean = 0, sd = 0.3))
})

LM <- simplify2array(LM)

system.time(GPA1 <- gpagen(LM, print.progress = FALSE, Proj = FALSE))
   user  system elapsed 
  0.020   0.004   0.025 
system.time(GPA2 <- ProcGPA(LM))
   user  system elapsed 
  0.172   0.003   0.176 
system.time(GPA3 <- procGPA(LM))
   user  system elapsed 
  7.858   0.095   8.091 

Updated Conclusion

  1. geomorph is faster much faster!
  2. But ProcGPA is pretty fast, as well.

What about sliding semilandmarks?

  • Let’s pit geomorph::gpagen vs. Morpho::ProcGPA
  • Let’s also consider long data (many specimens with fewer semilandmarks) and wide data (fewer specimens with many sliding landmarks).
  • Let’s use minimzed bending energy.
  • We also need to make sure the functions do the same thing because defaults are different. For these functions, forcing the number of iterations and the number of cores for parallel processing is key.

Simulated Examples for gpagen

Concerning computational efficiency in GPA, there are two “big data” attributes for which to be concerned with geometric morphometric (GM) data: wide data, which is a large number of landmarks (including semilandmarks); and long data, which is a large set of landmark configurations (specimens). These should be studied independently in comparative computational assessments, to determine if attempted optimization of analytical functions is successful only in certain regards. To generate data, we use an automated function, which generates a shape template that is a grid formed along \(x\) and \(y\) axes, with the height of points inversely associated with distance from center, creating a dome-like figure. The fraction of sliding semilandmarks and level of variation for isotropic random points applied to the template can also be varied.

get.array <- function(grid.n, spec.n, sdev= 0.5, f.surf = 0.5){
  x <- seq(0:grid.n) - mean(seq(0:grid.n))
  coord.t <- as.data.frame(expand.grid(x, x))
  colnames(coord.t) <- c("x", "y")
  coord.t$z <- apply(coord.t, 1, function(y) 0.5*(sqrt(max(x)^2) - sqrt(sum(y^2))))
  surf <- which(coord.t$z > quantile(coord.t$z, 1- f.surf))
  fixed <- (1:nrow(coord.t))[-surf]
  npk <- length(as.matrix(coord.t))
  coords <- lapply(1:spec.n, function(.) coord.t + rnorm(npk, sd = sdev))
  dims <- dim(coords[[1]])
  coords <- array(unlist(coords), c(dims, spec.n))
  list(template = coord.t, 
       coords = coords, 
       surf = surf, fixed = fixed)
}

Wide data example

Below is an example template of a dome from a \(32 \times 32\) grid, with 50% of the landmarks as sliders (shown in red), with 20 specimens, created with this function.

ex1 <- get.array(32, 20, 0.5, 0.5)

Before GPA

colors <- rep(1, nrow(ex1$template))
colors[ex1$surf] <- 2
plotAllSpecimens(ex1$coords,
                 plot_param = list(mean.bg = colors))

After GPA

GPA1 <- gpagen(ex1$coords, surf = ex1$surf, max.iter = 4, print.progress = FALSE)

plot(GPA1, plot_param = list(mean.bg = colors))

Now compare the two functions.

system.time(
  {slide <- slider3d(ex1$coords,
                   SMvector  = ex1$surf,
                   surp = ex1$surf,
                   mc.cores = 6,
                   iterations = 4,
                   silent = TRUE); 
  GPA2 <- ProcGPA(slide$dataslide)})
no surfaces specified - surface is approximated from point cloud
   user  system elapsed 
 48.722  28.171  24.055 
system.time(
  GPA1 <- gpagen(ex1$coords, 
                 surf = ex1$surf, 
                 max.iter = 4, 
                 Parallel = 6,
                 Proj = FALSE,
                 print.progress = FALSE))
   user  system elapsed 
 18.658  15.248  12.736 
two.b.pls(GPA1$coords, GPA2$rotated, print.progress = FALSE)
Data in either A1 or A2 do not have names.  It is assumed data in both A1 and A2 are ordered the same.

Call:
two.b.pls(A1 = GPA1$coords, A2 = GPA2$rotated, print.progress = FALSE) 



r-PLS: 0.996

Effect Size (Z): 0.5467

P-value: 0.172

Based on 1000 random permutations

Conclusions

  1. Comparable results
  2. gpagen is faster
  3. Each function has many more arguments to control operations. The ProcGPA function has more arguments for specific sliding objectives.

Long data example

ex2 <- get.array(5, 500, 0.4, 0.2)

Plot of just a few specimens:

colors <- rep(1, nrow(ex2$template))
colors[ex2$surf] <- 2
plotAllSpecimens(ex2$coords[,,1:20],
                 plot_param = list(mean.bg = colors))

Now let’s compare:

system.time(
  {slide <- slider3d(ex2$coords,
                   SMvector  = ex2$surf,
                   surp = ex2$surf,
                   mc.cores = 6,
                   iterations = 4,
                   silent = TRUE); 
  GPA2 <- ProcGPA(slide$dataslide)})
no surfaces specified - surface is approximated from point cloud
   user  system elapsed 
202.872  36.057  61.409 
system.time(
  GPA1 <- gpagen(ex2$coords, 
                 surf = ex2$surf, 
                 max.iter = 4, 
                 Parallel = 6,
                 Proj = FALSE,
                 print.progress = FALSE))
   user  system elapsed 
  1.328   0.827   0.733 
two.b.pls(GPA1$coords, GPA2$rotated, print.progress = FALSE)
Data in either A1 or A2 do not have names.  It is assumed data in both A1 and A2 are ordered the same.

Call:
two.b.pls(A1 = GPA1$coords, A2 = GPA2$rotated, print.progress = FALSE) 



r-PLS: 0.959

Effect Size (Z): 4.6202

P-value: 0.001

Based on 1000 random permutations

Conclusions

  1. Wowza!
  2. Morpho::ProcGPA is optimized for wide data.
  3. geomorph::gpagen is optimized for both wide and short data
  4. Only 20% of the landmarks were semilandmarks, so this could have been worse.