Maaslin_contrast_test() with median_comparison_abundance uses a z-test instead of t-test for linear mixed models

Hello, I think I’ve found a subtle bug in maaslin3 `maaslin_contrast_test()`.

For a bit of context, I had been running maaslin3 multiple times across many contrasts and then removing the duplicate contrasts after the fact. This was somewhat inefficient and I through it would be best to use the maaslin_contrast_test to speed that up and simplify the process.

After switching from the loop-dedup method to maaslin_contrast_test and testing the output, I found that everything is the same except for the pvalues which are much smaller for the same constrasts and same formula.

The root cause is that maaslin_contrast_test uses multcomp::glht z-test in its median-comparison step when everywhere else maaslin3 uses lmerTest::contest t-test (as far as I can tell). The z-test makes p-values much smaller than the t-test.

Also, looking at the documentation for maaslin_contrast_test it states that lmerTest::contest should be used for mixed effects linear.

Look more closely in R/utility_scripts.R, this bug is only happening when median_comparison_abundance = TRUE which is my case.

Here is a reprex using maaslin3’s datasets, I create what I would have thought to be the same pvalues using maaslin3 and maaslin_contrast_test, but they are not the same


library(maaslin3)

taxa <- read.csv(

  system.file("extdata", "HMP2_taxonomy.tsv", package = "maaslin3"),

  sep = "\\t",

  row.names = 1

)

meta <- read.csv(

  system.file("extdata", "HMP2_metadata.tsv", package = "maaslin3"),

  sep = "\\t",

  row.names = 1

)



\# 4 participants per diagnosis x 4 samples: few groups, so small Satterthwaite df

meta <- meta\[

  ave(seq_along(meta$participant_id), meta$participant_id, FUN = seq_along) <=

    4,

\]

ids <- unlist(lapply(split(meta, meta$diagnosis), function(m) {

  head(names(which(table(m$participant_id) == 4)), 4)

}))

meta <- meta\[meta$participant_id %in% ids, \]

meta$diagnosis <- factor(meta$diagnosis, levels = c("nonIBD", "UC", "CD"))

taxa <- taxa\[rownames(meta), colMeans(taxa\[rownames(meta), \] > 0) > 0.5\]



set.seed(1)

fit <- maaslin3(

  taxa,

  meta,

  output = tempfile(),

  formula = "\~ diagnosis + (1 | participant_id)",

  evaluate_only = "abundance",

  warn_prevalence = FALSE,

  save_models = TRUE,

  plot_summary_plot = FALSE,

  plot_associations = FALSE,

  verbosity = "ERROR"

)



\# Contrast that is exactly the diagnosisCD coefficient: same fit, same median, same null

L <- matrix(1, 1, 1, dimnames = list("CD_vs_nonIBD", "diagnosisCD"))

ct <- maaslin_contrast_test(fit, L, evaluate_only = "abundance")



main <- subset(

  fit$fit_data_abundance$results,

  name == "diagnosisCD",

  c(feature, coef, pval_individual)

)

both <- merge(

  main,

  ct$fit_data_abundance$results\[, c("feature", "pval_individual")\],

  by = "feature",

  suffixes = c("\_maaslin3", "\_contrast_test")

)

both <- both\[order(both$pval_individual_maaslin3), \]

both$is_same <- abs(both$pval_individual_maaslin3 - both$pval_individual_contrast_test) < 0.0001

print(both)



You can also see the issue if you put a browser() at line 979 of R/utility_scripts.R, and then run the reprex. It will show that the variable fit is class lmerModLmerTest and is tested using multcomp::glht.

Proposed fix

I think the fix is pretty straight forward, it would just be replaceing the multcomp::glht with lmerTest::contest in else { ... } at line 978 (link).

Replace this:


summary_out <- summary(multcomp::glht(

  fit,

  linfct = contrast_vec,

  rhs = offsets_to_test\[row_index\]

))$test

with this:


pval <- lmerTest::contest(fit,

                          matrix(contrast_vec, TRUE),

                          rhs = offsets_to_test\[row_index\])\[\['Pr(>F)'\]\]

coef <- contrast_vec %\*% lme4::fixef(fit)

sigma <- sqrt((contrast_vec %\*% vcov(fit) %\*%

                 t(contrast_vec))\[1, 1\])

summary_out <- list(pvalues = pval,

                    coefficients = coef,

                    sigma = sigma)

With this change, the reprex above gives identical p-values from maaslin3() and maaslin_contrast_test() for single-coefficient contrasts. I also verified with two other datasets, that I can’t share.

Also, happy to open a PR if that helps things along!

Thanks again for your package. I hope all this makes sense, I’m more of an R developer than a bioinformatics person so please let me kow if anything is not clear.

Cheers,

Eli Daniels

Hi,

Thanks for pointing this out. I agree with your fix, and I’ve updated the code on the GitHub, and it should be coming out in the next BioC release. The best way to install the updated code for now is to run: devtools::install_github("biobakery/maaslin3")

Let me know if there are still any issues.

Will

1 Like

Hi Will, thanks for that! I’ll let you know if I notice anything else.
Cheers,
Eli

Hi Will, that patch has gone a long way to reconciling the differences between maaslin output and maaslin_contrast_test output. There is potentially another issue that creates differences in pvalues between the two scenarios in the reprex above.

For datasets where some taxa are completely absent in one of the treatment groups, you cannot compare that group to anything, but you can still compare the other groups to each other. But maaslin_contrast_test ignores the taxa entirely as soon as one comparison is impossible. So those taxa absent from one comparisons vanish from all comparisons.

I am assuming this is not intended, but let me know if I’m wrong.

Reprex

You can see in the output that maaslin3() keeps the UC effect and marks CD as missing. The contrast test drops both rows, though the UC row never touches the missing coefficient.

# devtools::install_github("biobakery/maaslin3")
library(maaslin3)
taxa <- read.csv(system.file("extdata", "HMP2_taxonomy.tsv", package = "maaslin3"), sep = "\t", row.names = 1)
meta <- read.csv(system.file("extdata", "HMP2_metadata.tsv", package = "maaslin3"), sep = "\t", row.names = 1)
meta$diagnosis <- factor(meta$diagnosis, levels = c("nonIBD", "UC", "CD"))
taxa <- taxa[rownames(meta), colMeans(taxa[rownames(meta), ] > 0) > 0.5][, 1:5]

# Make one taxon absent from CD: its abundance fit then has no diagnosisCD coefficient
taxa[meta$diagnosis == "CD", 1] <- 0

fit <- maaslin3(taxa, meta, output = tempfile(),
  formula = "~ diagnosis + (1 | participant_id)", evaluate_only = "abundance",
  median_comparison_abundance = FALSE, warn_prevalence = FALSE, save_models = TRUE,
  plot_summary_plot = FALSE, plot_associations = FALSE, verbosity = "ERROR")

L <- diag(2)
dimnames(L) <- list(c("UC_vs_nonIBD", "CD_vs_nonIBD"), c("diagnosisUC", "diagnosisCD"))
ct <- maaslin_contrast_test(fit, L, evaluate_only = "abundance", median_comparison_abundance = FALSE)

# maaslin3() reports the UC effect for that taxon
subset(fit$fit_data_abundance$results, feature == colnames(taxa)[1], c(feature, name, coef, pval_individual))
# maaslin_contrast_test() drops both contrasts, including UC_vs_nonIBD which only needs diagnosisUC
subset(ct$fit_data_abundance$results, feature == colnames(taxa)[1], c(feature, test, coef, pval_individual, error))

Ouput:

# from maaslin3
   feature              name        coef            pval_individual
8  Phocaeicola_vulgatus diagnosisUC 0.1132017       0.9001771
10 Phocaeicola_vulgatus diagnosisCD       NaN             NaN

# from contrast test
  feature              test           coef            pval_individual      error
1 Phocaeicola_vulgatus UC_vs_nonIBD   NA              NA                   Predictors not in the model had non-zero contrast values
2 Phocaeicola_vulgatus CD_vs_nonIBD   NA              NA                   Predictors not in the model had non-zero contrast values

Proposed fix

The check at line 775 of R/utility_scripts.R looks at the whole contrast matrix at once and returns NA for every row of the feature if any row touches a coefficient the fit does not have. The condition is per row, so I think the check should be too. The per-row loops already exist just below, so it is a small change.

Replace this (lines 775-781):

if (any(contrast_mat_tmp[,setdiff(contrast_mat_cols, 
                                included_coefs)] != 0)) {
    return(matrix(rep(c(NA, NA, NA, 
        "Predictors not in the model had non-zero contrast values"), 
            nrow(contrast_mat)),
            nrow = nrow(contrast_mat), byrow = TRUE))
}

with this:

# A row that puts weight on a coefficient this fit does not have
# cannot be tested but the other rows still can
unsupported_rows <- rowSums(
    contrast_mat_tmp[, setdiff(contrast_mat_cols, included_coefs),
                    drop = FALSE] != 0) > 0

nd add this at the top of both FUN = function(row_num) { bodies, at line 788 (the glht branch) and line 835 (the contest branch), just before contrast_vec <- t(matrix(contrast_mat_tmp[row_num,])):

if (unsupported_rows[row_num]) {
    return(c(NA, NA, NA,
        "Predictors not in the model had non-zero contrast values"))
}

The error message stays the same but only affects rows that really can’t be tested instead of on all of them.

Hi,

I agree with this as well, and I’ve made and pushed the change on GitHub and BioC’s pre-release. The best way to install the new update is still devtools::install_github("biobakery/maaslin3")

Will